| name | apple-forge |
| description | Apple platform Clean Architecture development with Swift/SwiftUI. Covers 4-layer architecture (Entities, Domain, Data, Presentation), protocol-driven design, TDD workflow, hybrid repository pattern (OpenAPI + CoreData), real device testing with screenshot verification, XcodeGen project generation, and Carthage dependency management. Use this skill whenever working on Swift/SwiftUI iOS or macOS projects, implementing Clean Architecture layers, writing XCUITests or integration tests for Apple apps, setting up XcodeGen project.yml, configuring Carthage dependencies, building OpenAPI-based repositories, creating Seeds for test data, debugging real device test failures, reviewing test screenshots, or when the user mentions xcodebuild, xcresult, accessibility identifiers, ViewModels, Use Cases, or DependencyContainer in an Apple/Swift context. |
Apple Platform Clean Architecture & Testing
This skill defines how to build and test Apple platform apps using Clean Architecture with a pragmatic, real-device-verified testing strategy.
Architecture Overview
The app follows a 4-layer Clean Architecture where dependencies point inward — outer layers know about inner layers, never the reverse.
Presentation (SwiftUI Views, ViewModels, Coordinators)
↓
Data (Repositories, Network, Database, DTOs)
↓
Domain (Use Cases, Repository Protocols, Domain Services)
↓
Entities (Business Objects, Value Objects, Pure Swift)
The dependency rule is absolute: Entities import nothing. Domain imports only Entities. Data imports Domain + Entities. Presentation may import all inner layers through protocols.
Why This Matters
When a ViewModel needs data, it calls a Use Case protocol. The Use Case calls a Repository protocol. The concrete Repository in the Data layer implements that protocol. This means you can swap CoreData for Realm, or a local transport for a remote one, without touching business logic. It also means every layer is independently testable.
Layer Responsibilities
Entities
Pure Swift structs/classes with zero framework dependencies. These represent enterprise-wide business objects and rules. If it doesn't need UIKit, SwiftUI, or any framework — it belongs here.
Domain (Use Cases)
Application-specific business rules. Each Use Case handles one operation (Single Responsibility). Use Cases define repository protocols that the Data layer implements. Use Cases are async throws but should NOT be @MainActor.
Data
Repository implementations, network clients, database access. Converts external data formats to/from Entities. This is where OpenAPI clients, CoreData stacks, and DTOs live.
Presentation
SwiftUI views, ViewModels, and Coordinators. ViewModels MUST be @MainActor and handle UI state. They call Use Cases via Task { } blocks. ViewModels should not contain business logic — that belongs in Use Cases.
Protocol-Driven Design
All layer boundaries are defined by Swift protocols. Concrete implementations are injected via a DependencyContainer using a builder pattern. No concrete class from an outer layer may be referenced by an inner layer.
protocol NoteRepositoryProtocol {
func create(content: String, folderId: UUID?) async throws -> Note
func get(id: UUID) async throws -> Note?
}
class OpenAPINoteRepository: NoteRepositoryProtocol { ... }
@MainActor
class DependencyContainer {
static func create() -> DependencyContainer { ... }
func withCoreDataStack(_ stack: CoreDataStack) -> DependencyContainer { ... }
}
Hybrid Repository Architecture
Repositories split into two categories based on sync needs:
| Category | When to Use | Examples |
|---|
| OpenAPI Repositories | Data that may sync with a remote server | Notes, Folders, Users |
| Direct CoreData Repositories | Local-only data, no remote equivalent | Settings, Cache, Drafts |
OpenAPI repositories use Swift OpenAPI Generator (apple/swift-openapi-generator) with automatic transport selection — local CoreData transport for offline, URLSession transport for remote. The local transport is a real implementation, not a mock.
For detailed architecture diagrams, code examples, and project structure, see references/architecture.md.
Testing Strategy
The testing philosophy is: one real device UI test for smoke testing + non-UI integration tests for everything else.
Real device UI tests are inherently fragile (system dialogs, Face ID, timing). Only test on a real device what cannot be tested any other way. Business logic gets fast, reliable integration tests.
What Goes Where
Real device UI test (ONE test, CompleteFlowTests):
- App launches and basic UI renders
- Navigation between screens works
- Settings can be configured and saved
- Screenshots at every step for visual verification
Non-UI integration tests (many tests, fast, simulator):
- ViewModel logic and state transitions
- API service request/response formatting
- Data persistence (UserDefaults, Keychain)
- Use case business logic
- Error handling paths
TDD Cycle
All features follow Red-Green-Refactor:
- RED: Write a failing test that defines expected behavior
- GREEN: Write minimum code to make the test pass
- REFACTOR: Improve code while keeping tests green
Tests must pass before any human review. When AI assists, it must run all tests and verify they pass before stopping.
Seeds-Based Test Data
All test data comes from a shared Seeds/ folder. Only Seeds can call repository functions directly — tests interact through ViewModels.
@MainActor
struct TestSeeds {
static func createTestNote(using container: DependencyContainer,
content: String = "Test") async throws -> Note {
try await container.noteRepository.create(content: content, folderId: nil)
}
}
func testMoveNote() async throws {
let container = DependencyContainer.create()
.withCoreDataStack(InMemoryCoreDataStack())
let note = try await TestSeeds.createTestNote(using: container)
let viewModel = container.makeNoteEditorViewModel(noteId: note.id)
await viewModel.loadNote()
await viewModel.moveToFolder(folder.id)
XCTAssertEqual(viewModel.note?.folderId, folder.id)
}
Test Quality
- Each test tests ONE behavior
- Names follow:
test[Method]_[Scenario]_[ExpectedResult]
- Tests are independent (no execution order dependency)
- Use Arrange-Act-Assert pattern
- Assert on ViewModel state, not repository state
- Create ViewModels via DependencyContainer, never directly
Execution Order After Code Changes
This is critical — follow this exact order:
Step 1: Run Non-UI Tests First (fast, simulator)
xcodebuild test \
-project [AppName].xcodeproj \
-scheme [AppName] \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:[AppName]Tests \
-test-timeouts-enabled YES \
-maximum-test-execution-time-allowance 120 \
2>&1 | tee /tmp/unit-test-output.log
If any fail — STOP. Fix before proceeding. Do NOT run the UI test with broken unit tests.
Step 2: Run the ONE Real Device UI Test (slow, real device)
rm -rf /tmp/test-results.xcresult /tmp/test-output.log /tmp/test-screenshots
xcodebuild test \
-project [AppName].xcodeproj \
-scheme [AppName] \
-destination 'platform=iOS,id=DEVICE_UUID' \
-only-testing:[AppName]UITests/CompleteFlowTests/test_CompleteFlow \
-resultBundlePath /tmp/test-results.xcresult \
-allowProvisioningUpdates \
-test-timeouts-enabled YES \
-maximum-test-execution-time-allowance 180 \
2>&1 | tee /tmp/test-output.log
Step 3: Extract & Review Screenshots (MANDATORY)
mkdir -p /tmp/test-screenshots
xcparse screenshots /tmp/test-results.xcresult /tmp/test-screenshots
ls -1 /tmp/test-screenshots/ | sort
Read EVERY screenshot to visually verify: correct screen showing, elements rendered properly, no unexpected errors, text readable.
Step 4: Fix & Iterate
If something fails:
- Identify root cause from screenshots + logs
- Write a non-UI test that reproduces the issue
- Fix the code, verify non-UI test passes
- Re-run the UI test
For the complete testing workflow details (device setup, log streaming, troubleshooting), see references/testing.md.
Project Tooling
XcodeGen
The Xcode project is generated from project.yml. The .xcodeproj is NOT committed to version control.
Carthage
Dependencies managed via Carthage with --use-xcframeworks.
carthage bootstrap --platform iOS --use-xcframeworks
xcodegen generate
open [AppName].xcodeproj
Project Structure
project.yml
Cartfile / Cartfile.resolved
[AppName]/
├── Entities/Models/
├── Domain/UseCases/ + Interfaces/
├── Data/Repositories/ + Network/ + Persistence/ + API/Generated/
├── Presentation/Views/ + ViewModels/ + Coordinators/
└── DI/DependencyContainer.swift
[AppName]Tests/Seeds/ + Integration/ + Helpers/
[AppName]UITests/
openapi.yaml (if using OpenAPI repositories)
Platform Requirements
| Platform | Minimum | Key APIs |
|---|
| iOS | 17.0 | @Observable, navigationDestination(item:) |
| macOS | 14.0 | Same |
All UI-related classes (ViewModels, DependencyContainer) must be @MainActor. Repository and Use Case methods must be async throws.
Quick Reference
- Adding a feature: Define Entity → Write test → Define protocol → Implement repository → Define Use Case → Implement Use Case → Create Seeds → Write ViewModel integration test → Implement ViewModel → Implement View → Wire in DI → Run all tests
- Debugging UI test failure: Check screenshots → Check device logs (
idevicesyslog) → Write non-UI reproduction test → Fix → Re-run
- Logging on device: Use
os_log with %{public}@, never print() — print doesn't appear in device logs
- Accessibility IDs:
{purpose}TextField, {action}Button, {feature}Toggle, {content}Preview
- Element lookup in tests: Use
app.descendants(matching: .any)["myId"] to avoid element type mismatches