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+.

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