agentsclimarketplace

Swift feature scaffold mvvm clean arch

Skill esaldgut/ai-native-engineering-workspace/global-skills/apple/swift-feature-scaffold-mvvm-clean-arch

Scaffold one concrete feature end-to-end across Clean Architecture layers — SwiftUI View, @Observable @MainActor ViewModel with an explicit State enum, Sendable Repository protocol + implementation, Codable+Sendable DTO, and a Mapper — plus the cross-cutting UI rules: anchor a multi-step sheet with .sheet(item:) + presentationDetents, and handle cold start with a three-way branch (success / recoverable error / terminal auth error). Covers which layer gets @MainActor vs nonisolated, why .sheet(isPresented:) can't anchor a wizard step, and respecting Task cancellation on retry. Use when adding a new feature module (Feed, Profile, Checkout) and you want the file set, isolation, and error/loading states laid out correctly the first time.From its SKILL.md

Install
npx -y skills add esaldgut/ai-native-engineering-workspace --skill swift-feature-scaffold-mvvm-clean-arch

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

10.9 KB, ~2.3k tokens by cl100k_base, as published. Nobody here has run it

Feature scaffold (MVVM + Clean Architecture)

A feature module composes the layered architecture into one vertical slice: a SwiftUI View, an @Observable @MainActor ViewModel, a Sendable Repository protocol with its implementation, a Codable & Sendable DTO, and a Mapper from DTO to Domain. This skill is opinionated about that file set (a Repository between ViewModel and network is one of several valid choices), but the isolation and UI rules below are Apple-canonical. It builds on the module-level scaffold and uses Observable(), presentationDetents(_:), and the task(id:priority:_:) modifier.

When to invoke

  • You're adding a new feature (Feed, Profile, Checkout) and want the View/ViewModel/Repository/ DTO/Mapper set with the right isolation per file.
  • You're building a multi-step sheet/wizard and need it to anchor to the current step.
  • You're writing the cold-start load for a screen and need to branch success / retryable / terminal-auth.

Announce on invoke: "Using swift-feature-scaffold-mvvm-clean-arch to lay out the feature's layers, sheet anchoring, and cold-start states."

Do not reach for this for a throwaway screen with no remote data — a single SwiftUI View with local @State doesn't need a Repository or Mapper.

The canonical APIs (verified)

APISignature (verified)Use
@Observablemacro (iOS 17+); adds Observable conformance onlyViewModel observation
@MainActorglobal-actor attributeViewModel + View isolation
.sheet(item:onDismiss:content:)func sheet<Item, Content>(item: Binding<Item?>, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping (Item) -> Content) -> some View where Item : IdentifiableAnchor a sheet to a value/step
.presentationDetents(_:)func presentationDetents(_ detents: Set<PresentationDetent>) -> some ViewPartial/full sheet heights
.presentationDragIndicator(_:)func presentationDragIndicator(_ visibility: Visibility) -> some ViewGrabber on the sheet
.presentationBackgroundInteraction(_:)func presentationBackgroundInteraction(_:) -> some ViewLet content behind a partial sheet stay tappable
.task(id:priority:_:)re-runs the async task when id changes; auto-cancels on disappearCold-start load + reload
Resultenum Result<Success, Failure>Optional carrier for the 3-branch outcome

The rules (load-bearing)

1. Isolation per layer: View & ViewModel are MainActor; Repository & DTO are nonisolated/Sendable

A SwiftUI View's body is MainActor, so the View and its @Observable ViewModel are MainActor. The Repository protocol is Sendable and nonisolated (its conformer may be an actor). DTOs are Sendable value types. With per-target defaultIsolation (SE-0466) you don't annotate every type — the Feature target is MainActor by default, the Data/Domain targets nonisolated.

2. @Observable does not add @MainActor — declare both

@Observable @MainActor
public final class FeatureViewModel { /* ... */ }

3. Model the screen as an explicit State enum, not scattered booleans

A single enum State { case idle, loading, loaded([Item]), error(APIError) } makes the View a clean switch and makes impossible states unrepresentable (no isLoading && error != nil).

4. Anchor a wizard with .sheet(item:), never .sheet(isPresented:)

.sheet(isPresented: Bool) can only say "a sheet is up" — it can't carry which step. Drive a wizard with .sheet(item: $step) where Step: Identifiable, so changing the bound value swaps the step in place. Add presentationDetents for partial height; on iOS 26 the sheet adopts Liquid Glass automatically when a partial detent is set.

5. Cold start is a three-way branch, and retry respects cancellation

The initial .task must distinguish: (a) success, (b) a recoverable error (offline/timeout → show Retry), (c) a terminal error (auth invalid → route to sign-out). Any retry loop must check Task.isCancelled (or rely on .task's auto-cancel on disappear) so navigating away mid-load doesn't leak work.

func load() async {
    state = .loading
    do { state = .loaded(try await repo.list()) }
    catch is CancellationError { /* navigated away — do nothing */ }
    catch {
        let mapped = APIError.mapping(error)
        if case .unauthorized = mapped { onAuthInvalid() }    // terminal branch
        else { state = .error(mapped) }                        // recoverable branch
    }
}

Canonical example

// Domain
public protocol ItemRepository: Sendable { func list() async throws -> [Item] }

// Data — DTO + Mapper + impl
struct ItemDTO: Codable, Sendable { let id: String; let title: String }
enum ItemMapper { static func toDomain(_ dto: ItemDTO) -> Item { Item(id: UUID(uuidString: dto.id) ?? UUID(), title: dto.title) } }
public actor ItemRepositoryImpl: ItemRepository {
    private let client: HTTPClient
    public init(client: HTTPClient) { self.client = client }
    public func list() async throws -> [Item] {
        let dtos: [ItemDTO] = try await client.get("/items")
        return dtos.map(ItemMapper.toDomain)
    }
}

// Feature — ViewModel + View
@Observable @MainActor
public final class ItemListViewModel {
    public enum State { case idle, loading, loaded([Item]), error(APIError) }
    public private(set) var state: State = .idle
    private let repo: any ItemRepository
    private let onAuthInvalid: () -> Void
    public init(repo: any ItemRepository, onAuthInvalid: @escaping () -> Void) {
        self.repo = repo; self.onAuthInvalid = onAuthInvalid
    }
    public func load() async {
        state = .loading
        do { state = .loaded(try await repo.list()) }
        catch is CancellationError { }
        catch {
            let e = APIError.mapping(error)
            if case .unauthorized = e { onAuthInvalid() } else { state = .error(e) }
        }
    }
}

public struct ItemListView: View {
    @State private var vm: ItemListViewModel
    public init(vm: ItemListViewModel) { _vm = State(initialValue: vm) }
    public var body: some View {
        Group {
            switch vm.state {
            case .idle, .loading:      ProgressView()
            case .loaded(let items):   List(items) { Text($0.title) }
            case .error(let e):        ContentUnavailableView { Text(e.errorDescription ?? "Error") }
                                           actions: { Button("Retry") { Task { await vm.load() } } }
            }
        }
        .task { await vm.load() }                       // auto-cancels on disappear
    }
}

Decision aid: when NOT to / trade-offs

  • Repository indirection is a choice. For a trivial read-only screen, a ViewModel calling the client directly is defensible. The Repository earns its keep when you need caching, mocking for tests, or multiple backends behind one Domain contract.
  • Don't put auth-redirect logic in the View. The terminal branch belongs in the ViewModel (via the injected onAuthInvalid closure), so the navigation root stays the source of truth.
  • presentationBackgroundInteraction(.enabled(upThrough:)) only makes sense for a persistent partial sheet (a player/inspector); a modal wizard wants the default (background disabled).

Related skills

  • global-skills/apple/swift-clean-architecture-module-scaffold/SKILL.md — the per-target isolation this feature's files assume.
  • global-skills/apple/swift-localizederr-enum-patterns-ios26/SKILL.md — the APIError powering the .error state and the cold-start mapping.
  • global-skills/apple/swift-liquid-glass-design-system-ios26/SKILL.md — glass on the sheet/CTAs; honor "no glass on glass."
  • global-skills/meta/skill-pattern-freshness-audit/SKILL.md — re-checks observation/presentation APIs.

Sources


Last verified: 2026-06-03 against Apple Developer docs (Observable, presentationDetents, task(id:) signatures confirmed live) + SE-0466. .sheet(item:) confirmed as the step-anchoring form vs .sheet(isPresented:). Re-check after: WWDC26 / Swift 6.3, or by 2026-12-01. Decay risk: low (MVVM layering + sheet detents are stable since iOS 16–17). Found a drift? Run /skill-pattern-freshness-audit apple.

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 326,871. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.