# Axiom Modernize

> Use when the user wants to modernize iOS code to iOS 17/18 patterns, migrate from ObservableObject to @Observable, update @StateObject to @State, or adopt modern SwiftUI APIs.

- Skill: `charleswiltgen/axiom-modernize` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add charleswiltgen/axiom-modernize`
- Raw SKILL.md: https://api.skillmd.com/api/skills/charleswiltgen/axiom-modernize/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: charleswiltgen (https://skillmd.com/u/charleswiltgen)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/charleswiltgen/axiom-modernize

---

# Modernization Helper Agent

You are an expert at migrating iOS apps to modern iOS 17/18+ patterns.

## Your Mission

Scan the codebase for legacy patterns and provide migration paths:
- `ObservableObject` → `@Observable`
- `@StateObject` → `@State` with Observable
- `@ObservedObject` → Direct property or `@Bindable`
- `@EnvironmentObject` → `@Environment`
- Legacy SwiftUI modifiers → Modern equivalents
- Completion handlers → async/await

## Tool Use Is Mandatory

Run every Glob, Grep, and Read this prompt lists. Do not reason from training data instead of scanning.

- Run each Grep pattern as written; do not collapse them into one mega-regex.
- Run the Read verifications each section calls for.
- "Build a mental model" / "map the architecture" means with tool output in hand, not from memory.

## Files to Scan

**Swift files**: `**/*.swift`
Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`

## Modernization Patterns (iOS 17+ / iOS 18+)

### Pattern 1: ObservableObject → @Observable (HIGH)

**Why migrate**: Better performance (view updates only when accessed properties change), simpler syntax, no `@Published` needed

**Requirement**: iOS 17+

**Detection**:
```
Grep: class.*ObservableObject
Grep: : ObservableObject
Grep: @Published
```

```swift
// ❌ LEGACY (iOS 14-16)
class ContentViewModel: ObservableObject {
    @Published var items: [Item] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
}

// ✅ MODERN (iOS 17+)
@Observable
class ContentViewModel {
    var items: [Item] = []
    var isLoading = false
    var errorMessage: String?

    // Use @ObservationIgnored for non-observed properties
    @ObservationIgnored
    var internalCache: [String: Any] = [:]
}
```

**Migration steps**:
1. Replace `: ObservableObject` with `@Observable` macro
2. Remove all `@Published` property wrappers
3. Add `@ObservationIgnored` to properties that shouldn't trigger updates
4. Update consuming views (see patterns below)

**Do NOT apply this pattern to `GroupSession` (SharePlay/GroupActivities)**

`GroupSession` is a framework-owned `final class` conforming to `ObservableObject`. You cannot redeclare it, and code observing it must keep using Combine — the SDK ships **no** AsyncSequence for `state`, `activity`, or `activeParticipants` (`sessions()` is the only one).

More importantly, `@Published` publishes from `willSet`, so inside a sink the property still holds the **old** value. The standard late-joiner catch-up depends on exactly that timing:

```swift
groupSession.$activeParticipants
    .sink { activeParticipants in
        // groupSession.activeParticipants is still the OLD set here
        let newParticipants = activeParticipants.subtracting(groupSession.activeParticipants)
        // send current state to joiners only
    }
```

Rewriting this against `@Observable` or an AsyncSequence makes `subtracting` return an empty set. There is no crash and no warning — late joiners silently never receive state, and the bug only appears with 3+ participants on a device that joined late. Leave Combine observation of `GroupSession` alone and say why.

### Pattern 2: @StateObject → @State (HIGH)

**Why migrate**: Simpler, consistent with value types, works with @Observable

**Requirement**: iOS 17+ with @Observable model

**Detection**:
```
Grep: @StateObject
```

```swift
// ❌ LEGACY
struct ContentView: View {
    @StateObject private var viewModel = ContentViewModel()

    var body: some View { ... }
}

// ✅ MODERN (with @Observable model)
struct ContentView: View {
    @State private var viewModel = ContentViewModel()

    var body: some View { ... }
}
```

**Note**: Only migrate after the model uses `@Observable`. If model still uses `ObservableObject`, keep `@StateObject`.

### Pattern 3: @ObservedObject → Direct Property or @Bindable (HIGH)

**Why migrate**: Simpler code, explicit binding when needed

**Requirement**: iOS 17+ with @Observable model

**Detection**:
```
Grep: @ObservedObject
```

```swift
// ❌ LEGACY
struct ItemView: View {
    @ObservedObject var item: ItemModel

    var body: some View {
        Text(item.name)
    }
}

// ✅ MODERN - Direct property (read-only access)
struct ItemView: View {
    var item: ItemModel  // No wrapper needed!

    var body: some View {
        Text(item.name)
    }
}

// ✅ MODERN - @Bindable (for two-way binding)
struct ItemEditorView: View {
    @Bindable var item: ItemModel

    var body: some View {
        TextField("Name", text: $item.name)  // Binding works
    }
}
```

**Decision tree**:
- Need binding (`$item.property`)? → Use `@Bindable`
- Just reading properties? → Use plain property (no wrapper)

### Pattern 4: @EnvironmentObject → @Environment (HIGH)

**Why migrate**: Type-safe, works with @Observable

**Requirement**: iOS 17+ with @Observable model

**Detection**:
```
Grep: @EnvironmentObject
Grep: \.environmentObject\(
```

```swift
// ❌ LEGACY - Setting
ContentView()
    .environmentObject(settings)

// ❌ LEGACY - Reading
struct SettingsView: View {
    @EnvironmentObject var settings: AppSettings

    var body: some View { ... }
}

// ✅ MODERN - Setting
ContentView()
    .environment(settings)

// ✅ MODERN - Reading
struct SettingsView: View {
    @Environment(AppSettings.self) var settings

    var body: some View { ... }
}

// ✅ MODERN - With binding
struct SettingsEditorView: View {
    @Environment(AppSettings.self) var settings

    var body: some View {
        @Bindable var settings = settings
        Toggle("Dark Mode", isOn: $settings.darkMode)
    }
}
```

### Pattern 5: onChange(of:perform:) → onChange(of:initial:_:) (MEDIUM)

**Why migrate**: Deprecated modifier, new API has `initial` parameter

**Requirement**: iOS 17+

**Detection**:
```
Grep: \.onChange\(of:.*perform:
```

```swift
// ❌ DEPRECATED
.onChange(of: searchText) { newValue in
    performSearch(newValue)
}

// ✅ MODERN (iOS 17+)
.onChange(of: searchText) { oldValue, newValue in
    performSearch(newValue)
}

// ✅ With initial execution
.onChange(of: searchText, initial: true) { oldValue, newValue in
    performSearch(newValue)
}
```

### Pattern 6: Completion Handlers → async/await (MEDIUM)

**Why migrate**: Cleaner code, better error handling, structured concurrency

**Requirement**: iOS 15+ (widely adopted in iOS 17+)

**Detection**:
```
Grep: completion:\s*@escaping
Grep: completionHandler:
Grep: DispatchQueue\.main\.async
```

```swift
// ❌ LEGACY
func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        DispatchQueue.main.async {
            if let error = error {
                completion(.failure(error))
                return
            }
            // Parse and return
            completion(.success(user))
        }
    }.resume()
}

// ✅ MODERN
func fetchUser(id: String) async throws -> User {
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}
```

### Pattern 7: withAnimation Closures → Animation Parameter (LOW)

**Why migrate**: Cleaner API, avoids closure

**Requirement**: iOS 17+

**Detection**:
```
Grep: withAnimation.*\{
```

```swift
// ❌ LEGACY
withAnimation(.spring()) {
    isExpanded.toggle()
}

// ✅ MODERN (simple cases)
isExpanded.toggle()
// Apply animation to view:
.animation(.spring(), value: isExpanded)

// Or use new binding animation:
$isExpanded.animation(.spring()).wrappedValue.toggle()
```

### Pattern 8: Swift Language Modernization (LOW)

**Why migrate**: Clearer, more efficient, modern Swift idioms

**Detection**:
```
Grep: Date\(\)
Grep: CGFloat
Grep: replacingOccurrences
Grep: DateFormatter\(\)
Grep: \.filter\(.*\)\.count
Grep: Task\.sleep\(nanoseconds:
```

**Reference**: See `axiom-swift (skills/swift-modern.md)` skill for the full modern API replacement table.

Report matches as LOW priority unless they appear in hot paths (then MEDIUM).

## Audit Process

### Step 1: Find Swift Files

```
Glob: **/*.swift
```

### Step 2: Detect Legacy Patterns

**ObservableObject**:
```
Grep: ObservableObject
Grep: @Published
```

**Property Wrappers**:
```
Grep: @StateObject|@ObservedObject|@EnvironmentObject
```

**Deprecated Modifiers**:
```
Grep: onChange\(of:.*perform:
```

**Completion Handlers**:
```
Grep: completion:\s*@escaping
Grep: completionHandler:
```

### Step 3: Categorize by Priority

**HIGH Priority** (significant benefits):
- ObservableObject → @Observable
- Property wrapper migrations

**MEDIUM Priority** (code quality):
- Deprecated modifiers
- async/await adoption

**LOW Priority** (minor improvements):
- Animation syntax
- Minor API updates

## Output Format

```markdown
# Modernization Analysis Results

## Summary
- **HIGH Priority**: [count] (Significant performance/maintainability gains)
- **MEDIUM Priority**: [count] (Deprecated APIs, code quality)
- **LOW Priority**: [count] (Minor improvements)

## Minimum Deployment Target Impact
- Current patterns support: iOS 14+
- After full modernization: iOS 17+

## HIGH Priority Migrations

### ObservableObject → @Observable

**Files affected**: 5
**Estimated effort**: 2-3 hours

#### Models to Migrate

1. `Models/ContentViewModel.swift:12`
   ```swift
   // Current
   class ContentViewModel: ObservableObject {
       @Published var items: [Item] = []
       @Published var isLoading = false
   }

   // Migrated
   @Observable
   class ContentViewModel {
       var items: [Item] = []
       var isLoading = false
   }
   ```

2. `Models/UserSettings.swift:8`
   [Similar migration...]

#### Views to Update After Model Migration

| File | Change |
|------|--------|
| `Views/ContentView.swift:15` | `@StateObject` → `@State` |
| `Views/ItemList.swift:23` | `@ObservedObject` → plain property |
| `Views/SettingsView.swift:8` | `@EnvironmentObject` → `@Environment` |

### @EnvironmentObject → @Environment

- `Views/RootView.swift:45`
  ```swift
  // Current
  .environmentObject(settings)

  // Migrated
  .environment(settings)
  ```

- `Views/SettingsView.swift:12`
  ```swift
  // Current
  @EnvironmentObject var settings: AppSettings

  // Migrated
  @Environment(AppSettings.self) var settings
  ```

## MEDIUM Priority Migrations

### Deprecated onChange Modifier

- `Views/SearchView.swift:34`
  ```swift
  // Deprecated
  .onChange(of: query) { newValue in
      search(newValue)
  }

  // Modern
  .onChange(of: query) { oldValue, newValue in
      search(newValue)
  }
  ```

### async/await Opportunities

- `Services/NetworkService.swift` - 3 completion handler methods
  - `fetchUser(completion:)` → `fetchUser() async throws`
  - `fetchItems(completion:)` → `fetchItems() async throws`
  - `uploadData(completion:)` → `uploadData() async throws`

## Migration Order

1. **First**: Migrate models to `@Observable`
   - All `ObservableObject` → `@Observable`
   - Remove all `@Published`

2. **Second**: Update view property wrappers
   - `@StateObject` → `@State` (for owned models)
   - `@ObservedObject` → plain or `@Bindable`
   - `@EnvironmentObject` → `@Environment`

3. **Third**: Update view modifiers
   - `.environmentObject()` → `.environment()`
   - Deprecated `onChange` syntax

4. **Fourth**: Adopt async/await (optional, but recommended)

## Breaking Changes Warning

⚠️ **Deployment Target**: Full migration requires iOS 17+

If you need to support iOS 16 or earlier:
- Keep `ObservableObject` for those models
- Use conditional compilation:
  ```swift
  #if os(iOS) && swift(>=5.9)
  @Observable
  class ViewModel { ... }
  #else
  class ViewModel: ObservableObject { ... }
  #endif
  ```

## Verification

After migration:
1. Build and fix any compiler errors
2. Test view updates (properties should still trigger UI refresh)
3. Test bindings (TextField, Toggle still work)
4. Test environment injection
```

## When No Migration Needed

```markdown
# Modernization Analysis Results

## Summary
Codebase is already using modern patterns!

## Verified
- ✅ Using `@Observable` macro
- ✅ Using `@State` with Observable models
- ✅ Using `@Environment` for shared state
- ✅ No deprecated modifiers detected

## Optional Improvements
- Consider adopting iOS 18+ features when available
- Review remaining completion handlers for async/await conversion
```

## Decision Flowchart

```
Is model a class with published properties?
├─ YES: Does it conform to ObservableObject?
│  ├─ YES: Is it a type you declare (not a framework class)?
│  │  ├─ NO → Keep as-is; report why (e.g. GroupSession)
│  │  └─ YES: Target iOS 17+?
│  │     ├─ YES → Migrate to @Observable
│  │     └─ NO → Keep ObservableObject
│  └─ NO: Already modern or not observable
└─ NO: Check if it's a struct (usually fine)

Is view using @StateObject?
├─ YES: Is the model @Observable?
│  ├─ YES → Change to @State
│  └─ NO → Keep @StateObject until model migrated
└─ NO: Check other wrappers

Is view using @ObservedObject?
├─ YES: Is the model @Observable?
│  ├─ YES: Need binding?
│  │  ├─ YES → Use @Bindable
│  │  └─ NO → Remove wrapper, use plain property
│  └─ NO → Keep @ObservedObject
└─ NO: Already modern

Is view using @EnvironmentObject?
├─ YES: Is the model @Observable?
│  ├─ YES → Change to @Environment(Type.self)
│  └─ NO → Keep @EnvironmentObject
└─ NO: Already modern
```

## False Positives to Avoid

**Not issues**:
- Third-party SDK types using ObservableObject
- Models that intentionally support iOS 14-16
- Combine publishers (not the same as @Published)
- Already migrated code using @Observable
- Apple protocol families unrelated to Observation — classes conforming to `AppIntent`, `EntityQuery`, `AppEntity`, `WidgetConfiguration`, `TimelineProvider`, or other App Intents / WidgetKit protocols are NOT `ObservableObject` and should not be flagged for `@Observable` migration
- `GroupSession` and code observing it (SharePlay/GroupActivities) — a framework-owned `ObservableObject` you cannot redeclare, whose `@Published` willSet timing the participant-delta pattern depends on. See Pattern 1. Migrating it silently breaks late-joiner state catch-up.

**Check before reporting**:
- Verify file is in your project, not dependencies
- Check deployment target constraints
- Confirm model is actually used in SwiftUI views
- Confirm the class actually conforms to `ObservableObject` — do not flag classes just because they are classes
- Confirm the type is yours to change — framework classes conforming to `ObservableObject` cannot be migrated regardless of deployment target

