DevPortfolio — Architecture
TCA structure
AppFeature
├── OnboardingFeature
├── ResumeParserFeature
│ ├── PDFTextExtractor (Vision)
│ └── Classifier (CoreML)
├── PortfolioFeature
│ ├── ProfileFeature
│ ├── ExperienceListFeature
│ ├── ProjectListFeature
│ ├── SkillsFeature
│ └── EducationFeature
├── ShareFeature
└── ARSessionFeature
Each feature is a Reducer with its own State, Action, and body. Parent features compose children via Scope.
@Reducer
public struct AppFeature {
@ObservableState
public struct State: Equatable {
public var onboarding: OnboardingFeature.State?
public var portfolio: PortfolioFeature.State
public var resumeParser: ResumeParserFeature.State?
public var ar: ARSessionFeature.State?
}
public enum Action {
case onboarding(OnboardingFeature.Action)
case portfolio(PortfolioFeature.Action)
case resumeParser(ResumeParserFeature.Action)
case ar(ARSessionFeature.Action)
case startResumeImport(URL)
case enterAR
}
public var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .startResumeImport(let url):
state.resumeParser = .init(sourceURL: url)
return .none
case .resumeParser(.delegate(.parsingComplete(let portfolio))):
state.portfolio = portfolio
state.resumeParser = nil
return .none
case .enterAR:
state.ar = .init(projects: state.portfolio.projects)
return .none
// ...
default:
return .none
}
}
.ifLet(\.onboarding, action: \.onboarding) { OnboardingFeature() }
.ifLet(\.resumeParser, action: \.resumeParser) { ResumeParserFeature() }
.ifLet(\.ar, action: \.ar) { ARSessionFeature() }
Scope(state: \.portfolio, action: \.portfolio) { PortfolioFeature() }
}
}
ML pipeline
PDF/Image
│
▼
[VNRecognizeTextRequest] ← Vision (built-in, no model required)
│ produces text blocks
▼
[Paragraph splitter] ← simple newline + heuristics
│
▼
[Text Classifier] ← our CoreML model
│ produces {label, confidence}[]
▼
[Section builder] ← group consecutive same-label paragraphs
│
▼
Portfolio (Profile, Experience, Skills, Projects, Education)
The CoreML model is a text classifier trained via Create ML. Training data: ~500 manually labeled paragraphs from anonymized resumes (Kaggle has open datasets we can curate). Five classes: experience, skill, education, project, other.
Inference is sub-millisecond per paragraph on modern devices. A 1-page resume has maybe 20 paragraphs → 20 ms classifier overhead, dominated by the Vision text extraction (~2 s).
AR session lifecycle
import RealityKit
import ARKit
@MainActor
public final class ARSessionController: ObservableObject {
public let arView = ARView(frame: .zero)
@Published public var placedCards: [UUID: Entity] = [:]
public func start() {
let config = ARWorldTrackingConfiguration()
config.planeDetection = [.horizontal]
config.environmentTexturing = .automatic
arView.session.run(config)
}
public func placeCard(for project: Project, on plane: ARPlaneAnchor) {
let anchor = AnchorEntity(plane: .horizontal)
let card = makeCardEntity(for: project)
anchor.addChild(card)
arView.scene.addAnchor(anchor)
placedCards[project.id] = card
}
private func makeCardEntity(for project: Project) -> Entity {
// A flat card mesh with the project's image as a texture
let mesh = MeshResource.generatePlane(width: 0.3, height: 0.4, cornerRadius: 0.02)
var material = SimpleMaterial(color: .white, isMetallic: false)
// ... add texture from project image
let entity = ModelEntity(mesh: mesh, materials: [material])
return entity
}
}
ARSessionFeature in TCA wraps this controller. The reducer sends “plane detected” actions; the controller renders.
Module layout
DevPortfolio/
App/
Packages/
DevPortfolioCore/ # models, errors
DevPortfolioML/ # CoreML wrapper, paragraph splitter
DevPortfolioFeatures/ # all TCA reducers
DevPortfolioUI/ # SwiftUI views
DevPortfolioAR/ # ARSessionController + RealityKit scenes
The ML module is isolated so we can swap models (e.g., move to a larger LLM later) without touching features.
ADRs
ADR-001: TCA over MV or MVVM
For an app with 4+ major flows (onboarding, parsing, portfolio, AR) and complex shared state, TCA’s deterministic reducer model pays off. The state tree is the single source of truth; effects are explicit; tests are pure-function assertions. Cost: more boilerplate up front. Benefit: any state bug can be reproduced from a sequence of actions.
ADR-002: On-device ML, no server fallback
User-uploaded resumes are sensitive. Shipping them to a server — even our own — is a worse story than running a slightly less accurate model on-device. Modern iPhones run CoreML text models with imperceptible latency; the accuracy ceiling is high enough for our 80% target.
ADR-003: Create ML, not a third-party training pipeline
Create ML produces .mlmodel files Apple optimizes for Apple Silicon. Training is faster than PyTorch + CoreMLTools for our scale, the developer ergonomics are better, and we get on-device Neural Engine acceleration for free. For an LLM-scale task we’d use PyTorch + CoreMLTools; for paragraph classification, Create ML is the right tool.
ADR-004: ARKit + RealityKit, not SceneKit
RealityKit is the modern path: ECS-based, designed for AR, integrates with ARView cleanly. SceneKit is legacy and missing AR-first features (people occlusion, environment texturing). For new AR work, RealityKit is the default in 2024+.
ADR-005: QR code links, not deep links from email
QR codes are friction-free at meetups. The link can be a universal link (https://yourorg.com/p/{id}) that opens the app if installed or falls back to a web preview. Email-based sharing is slower and less impressive in person.
Threading
- TCA reducers are pure and called on the main queue by the Store.
- Effects (ML inference, file I/O) run on background tasks.
- ARView updates on the main thread; physics + rendering happen on RealityKit’s render thread automatically.
Next: Implementation guide