Author: Smin Rana

  • Stop Over-Engineering: A Minimal Architecture Pattern for Solo Devs

    Stop Over-Engineering: A Minimal Architecture Pattern for Solo Devs

    If you’re building apps alone, architecture can feel like a trap.

    On one side: the wild west. Everything talks to everything, your UI calls the API directly, business rules end up copy/pasted, and a “quick fix” turns into a permanent mess.

    On the other side: over-engineering. You get sold a stack of patterns — clean architecture, CQRS, event sourcing, hexagonal adapters, 12 modules, 40 folders, and a dependency injection graph that needs its own diagram. You haven’t shipped yet, but you already have “infrastructure.”

    Solo development doesn’t need a philosophy war. You need a repeatable structure that:

    • keeps you shipping
    • makes debugging obvious
    • keeps changes local
    • doesn’t require meetings (because you are the meeting)

    This guide gives you a minimal architecture pattern that works across Flutter, React Native, iOS (SwiftUI), and Android (Compose). It’s intentionally boring. It’s not “enterprise.” It is a small set of rules that stays useful from your first screen to your first paying customer — and still won’t collapse when you add features.

    Related guides on sminrana.com:

    Why solo devs over-engineer (and why it hurts)

    Over-engineering isn’t about intelligence. It’s usually about anxiety.

    When you’re solo, every future risk feels personal:

    • “What if I need to swap API providers later?”
    • “What if this becomes huge?”
    • “What if I need offline mode?”
    • “What if I hire contractors?”

    So you build a fortress. You add abstractions “just in case.” You separate everything into layers before you even know what the app does. Your code becomes a museum of hypothetical problems.

    The cost shows up fast:

    • Slower iteration: each change crosses five files and three folders.
    • Lower confidence: you can’t tell where the truth lives.
    • More bugs: abstractions hide flows that should be explicit.
    • Higher cognitive load: you spend energy navigating structure instead of building.

    The goal isn’t “no architecture.” The goal is the smallest architecture that gives you leverage.

    What “minimal architecture” actually means

    Minimal doesn’t mean tiny; it means necessary.

    A minimal architecture is one where:

    • every layer exists to solve a real problem you’ve already hit
    • boundaries are clear enough that you can replace parts without collateral damage
    • most code is easy to delete later

    When you find yourself asking, “Is this clean?”, ask a different question:

    “If I rewrite this feature in two weeks, will it be painful?”

    Minimal architecture optimizes for inevitable rewrites.

    The pattern: a strict 3-layer architecture

    This is the entire model:

    1. UI layer: screens and state holders. Renders state, triggers actions.
    2. Domain layer: use-cases and domain models. Pure business logic, no framework types.
    3. Data layer: repositories and data sources. Talks to network, database, storage.

    The magic isn’t the layers — you’ve heard that before. The magic is the strict dependency direction:

    • UI can depend on Domain.
    • Domain can depend on nothing (or only simple shared utilities).
    • Data can depend on Domain models/interfaces.
    • Domain never imports Data.
    • UI never imports raw network DTOs.

    That’s it.

    If you keep those rules, you get the benefits people chase with complex architectures:

    • You can test business logic quickly.
    • You can stub data for UI development.
    • You can swap storage and API details.
    • You can refactor without fear.

    And you can do it without building a cathedral.

    When you should (and shouldn’t) use this

    If you’re a solo developer building:

    • a mobile app with login + CRUD + subscriptions
    • a tool for a niche audience
    • a micro-SaaS companion app
    • an MVP you want to ship in days/weeks

    …this is a great default.

    When not to use it as-is:

    • You’re building a high-frequency trading platform. (Not your blog audience, likely.)
    • You already have a large team with a strict architecture and shared tooling.
    • Your app is extremely tiny (one screen, no API). For that, keep it simpler and skip the layers.

    Minimal architecture is not a religion. It’s a starting point.

    The core principles (print this and stick it above your desk)

    1) Patterns follow problems

    Don’t add a layer to prove you’re serious. Add a layer when you feel pain:

    • repeated code
    • impossible-to-test logic
    • coupling that makes changes risky

    If the pain isn’t there yet, write the boring code and move on.

    2) Make boundaries obvious

    Your future self should be able to answer:

    • “Where does this rule live?”
    • “What calls what?”
    • “Where does caching happen?”

    If you can’t answer in 10 seconds, the abstraction isn’t helping.

    3) Keep state flows explicit

    Most mobile bugs are “state is wrong.” Your architecture should make it hard to hide state mutations.

    A good rule of thumb:

    • UI owns UI state.
    • Domain owns business decisions.
    • Data owns side effects.

    4) Fewer dependencies, fewer problems

    Every third-party dependency is an additional system you must mentally simulate.

    Default to:

    • platform standard libraries
    • small, well-known libraries
    • first-party tools

    If you pull in a library, write down what you’re buying and what you’re paying.

    Layer 1: UI (screens + state holders)

    The UI layer has two jobs:

    1. render data
    2. dispatch user intent

    It should not:

    • parse JSON
    • decide pricing rules
    • format error categories
    • implement caching logic

    The UI can contain:

    • views/screens/components
    • view models / controllers / state notifiers
    • UI-specific mappers (like mapping a domain model to displayed strings)

    A simple UI rule that prevents chaos

    UI can call only use-cases (or a small facade) — not repositories, and never raw API clients.

    That rule does two things:

    • It forces business logic out of the UI.
    • It creates a stable “API” for the UI, even while data changes.

    What UI state should look like

    Avoid “a dozen booleans” state.

    Prefer a single object (sealed class / union type / enum + payload) that represents the screen:

    • Loading
    • Empty
    • Content
    • Error

    Example TypeScript (React Native):

    type TodosState =
      | { kind: "loading" }
      | { kind: "empty" }
      | { kind: "content"; items: Todo[] }
      | { kind: "error"; message: string; canRetry: boolean };

    This makes it hard to end up in impossible states like loading=true and error=trueand items!=[].

    Layer 2: Domain (use-cases + models)

    The domain layer is the brain of your app.

    It contains:

    • domain models: plain data structures that represent concepts the user cares about
    • use-cases: single-purpose operations like CreateTodoFetchProfileSubmitOrder

    A use-case is not a “service with 20 methods.” It is a function/class that does one job.

    What belongs in the domain layer

    Business rules, not UI rules.

    Examples:

    • “A trial can be started once per user.”
    • “A booking must be within the next 180 days.”
    • “VAT applies only if country is X.”
    • “If the network fails, fallback to cached data if it’s fresh enough.” (Yes, staleness rules are business rules.)

    Examples of what does not belong in the domain layer:

    • “Show a toast”
    • “Use Material 3 colors”
    • “Debounce button taps by 500ms” (UI concern)

    Domain types must be framework-free

    This rule matters more than people realize.

    Your domain models shouldn’t import:

    • Flutter BuildContext
    • React useState
    • SwiftUI View
    • Kotlin Context
    • network client classes

    Domain should be boring and portable.

    That’s not because you’ll reuse it across apps (sometimes you will, often you won’t). It’s because when types are “clean,” tests and refactors become easy.

    Example: use-case + repository contract

    Example contract (TypeScript):

    export interface TodoRepo {
      list(): Promise<Todo[]>;
      add(title: string): Promise<Todo>;
      toggle(id: string): Promise<Todo>;
    }

    Use-case:

    export class AddTodo {
      constructor(private repo: TodoRepo) {}
    
      async run(title: string): Promise<Todo> {
        const trimmed = title.trim();
        if (trimmed.length < 3) {
          throw new Error("Title too short");
        }
        return this.repo.add(trimmed);
      }
    }

    Notice what’s missing:

    • no HTTP
    • no storage
    • no UI

    Just the rule.

    Work in “nouns” and “verbs”

    A helpful mental model:

    • nouns: UserTodoOrderPlan
    • verbs: CreateOrderApplyCouponFetchTodos

    If you name things this way, your architecture stays grounded in product behavior, not technology.

    Layer 3: Data (repositories + sources)

    Data is where side effects live.

    It contains:

    • repository implementations
    • remote data sources (API clients)
    • local data sources (database, key-value storage)
    • DTOs and mapping

    This is the only layer that should know how data is stored or fetched.

    Repository interfaces vs implementations

    If you’re building solo, you don’t need 20 interfaces for the sake of it. You need interfaces at boundaries where swap-ability is valuable:

    • the boundary between domain and data (so use-cases can be tested without network)
    • the boundary between repository and its sources (so caching/offline can change without breaking use-cases)

    Keep the interface small. If it grows, your model is unclear.

    DTO mapping: keep the mess contained

    Network JSON is often:

    • inconsistent
    • abbreviated
    • not quite correct
    • likely to change

    That mess should not leak into domain.

    Instead, map DTO → domain model at the data boundary.

    Example:

    type TodoDTO = { id: string; t: string; d: 0 | 1 };
    
    function mapTodoDto(dto: TodoDTO): Todo {
      return { id: dto.id, title: dto.t, done: dto.d === 1 };
    }

    This is boring. That’s why it works.

    A practical caching approach (without building a spaceship)

    If you need caching, don’t invent “cache managers.” Put caching in the repository.

    A simple approach:

    • repository tries remote
    • on success: store locally and return
    • on failure: try local if available

    You can later add “cache freshness” rules without changing the UI or domain.

    The dependency rules (the part you must not break)

    Write these rules in your CONTRIBUTING.md or keep them as a team agreement (even if the team is you):

    • UI imports Domain.
    • Domain imports nothing (except shared primitives).
    • Data imports Domain.
    • Domain exposes interfaces; Data implements them.
    • DTOs stay in Data.
    • UI never sees DTOs.

    If you violate these rules, you’re not “being pragmatic.” You’re creating invisible coupling.

    Directory structure that stays sane

    Folder structure isn’t architecture, but it strongly influences how you think.

    Here’s a structure that works across frameworks:

    Flutter

    lib/
      ui/
        screens/
        widgets/
        state/
      domain/
        models/
        usecases/
        repos/
      data/
        repos/
        sources/
        dto/
        mappers/

    React Native

    src/
      ui/
        screens/
        components/
        state/
      domain/
        models/
        usecases/
        repos/
      data/
        repos/
        api/
        storage/
        dto/
        mappers/

    iOS (Swift)

    App/
      UI/
        Screens/
        Components/
        State/
      Domain/
        Models/
        UseCases/
        Repos/
      Data/
        Repos/
        Remote/
        Local/
        DTO/
        Mappers/

    Android (Kotlin)

    app/src/main/java/.../
      ui/
        screen/
        state/
      domain/
        model/
        usecase/
        repo/
      data/
        repo/
        remote/
        local/
        dto/
        mapper/

    This is intentionally repetitive. Repetition is clarity.

    A complete end-to-end example (the “feature slice” method)

    When solo devs get stuck, it’s often because they’re building architecture “horizontally”:

    • build all models
    • build all repositories
    • build all screens

    That approach creates a giant gap between code and value.

    Build “feature slices” instead: one end-to-end vertical path at a time.

    Let’s do an example: “Add a todo item.”

    Step 1: Domain model

    Keep it simple.

    export type Todo = {
      id: string;
      title: string;
      done: boolean;
    };

    Step 2: Repository interface in domain

    export interface TodoRepo {
      add(title: string): Promise<Todo>;
    }

    Step 3: Use-case in domain

    export class AddTodo {
      constructor(private repo: TodoRepo) {}
    
      async run(input: { title: string }): Promise<Todo> {
        const title = input.title.trim();
        if (title.length === 0) throw new Error("Title required");
        if (title.length > 140) throw new Error("Title too long");
        return this.repo.add(title);
      }
    }

    Step 4: Data implementation

    Create an API DTO, a mapper, and the repository implementation.

    type TodoDTO = { id: string; title: string; done: boolean };
    
    function dtoToDomain(dto: TodoDTO): Todo {
      return { id: dto.id, title: dto.title, done: dto.done };
    }
    
    export class HttpTodoRepo implements TodoRepo {
      constructor(
        private client: { post: (path: string, body: any) => Promise<any> },
      ) {}
    
      async add(title: string): Promise<Todo> {
        const res = await this.client.post("/todos", { title });
        return dtoToDomain(res as TodoDTO);
      }
    }

    Even if you later change networking libraries, everything above stays stable.

    Step 5: UI wiring

    UI calls the use-case, renders output.

    The UI layer doesn’t know about DTOs or HTTP; it knows only AddTodo.

    This gives you a small, testable integration seam: you can pass a fake repo in tests, or a stub repo during early prototyping.

    Error handling without drama

    Most architecture posts talk about error handling as if you need a framework.

    What you really need is consistency.

    A minimal error strategy

    • Data layer converts low-level failures into typed failures (network down, unauthorized, timeout).
    • Domain decides what to do (retry? fallback? stop?).
    • UI decides how to show it (message + action).

    If you want a single simple type, use “Result” style return values.

    Kotlin sealed results:

    sealed class Result<out T> {
      data class Ok<T>(val value: T): Result<T>()
      data class Err(val error: Throwable): Result<Nothing>()
    }

    Swift:

    enum Result<T> {
      case ok(T)
      case err(Error)
    }

    Dart and TypeScript have similar patterns (or you can use exceptions carefully). Pick one and use it consistently.

    A rule that saves time

    Never show raw technical errors to users.

    Instead, map technical issues into a small set of user-friendly categories:

    • “You’re offline.”
    • “Session expired, please log in again.”
    • “Something went wrong. Try again.”

    Put the mapping at the UI edge, where copy is managed.

    Observability lite: logs that actually help

    Solo devs often skip observability until it’s painful. Then they add too much.

    Minimal observability looks like this:

    • Log at boundaries, not everywhere.
    • Include one correlation ID per user flow.
    • Track 3–5 events that represent product progress.

    Examples of boundary logs:

    • UI: user tapped “Pay”
    • Domain: order validated, coupon applied
    • Data: POST /checkout started/finished, status code

    This is enough to debug 80% of issues without a “logging architecture.”

    Performance budgets that don’t waste your life

    Over-engineering often hides performance work behind abstractions.

    Minimal performance practice:

    • define a startup target (e.g., “home screen in under 2 seconds on mid-range phone”)
    • define a screen render budget (“no frame drops in main scrolling screens”)
    • watch bundle size (especially RN)

    Then apply boring optimizations:

    • lazy-load heavy dependencies
    • defer non-critical initialization
    • compress images
    • cache simple responses

    You don’t need a “performance layer.” You need a few measurable constraints.

    Testing that matters for solo devs

    You don’t need 90% coverage. You need confidence.

    The testing priority list

    1. Domain unit tests: fastest, most valuable.
    2. Repository integration tests: verify mapping + caching rules.
    3. UI smoke tests: only for critical flows.

    What to test in the domain

    Test:

    • validation rules
    • decision rules (what happens with edge cases)
    • transformations (prices, dates, sorting)

    Don’t test:

    • widgets rendering every pixel
    • HTTP client correctness (that’s the library’s job)

    The solo-dev litmus test

    If a test takes 30 seconds to run, you’ll stop running it.

    Build a test set that runs in seconds, and you’ll actually use it.

    Framework blueprints (practical mappings)

    This pattern isn’t tied to a specific state manager or DI library. Here’s how it translates cleanly.

    Flutter

    • UI: Widgets + state holders (ChangeNotifier, Riverpod Notifier, Bloc, etc.)
    • Domain: pure Dart models + use-case classes/functions
    • Data: repositories wrapping Dio/http + Hive/Isar/SharedPreferences

    A minimal Flutter wiring approach:

    • Create repository implementations in data/
    • Pass them into use-cases
    • Inject use-cases into state holders

    If you use Riverpod, your providers become the wiring layer. Keep them thin.

    React Native

    • UI: screens + hooks
    • Domain: use-case classes/functions (plain TS)
    • Data: API client + storage

    Keep UI state local unless it truly spans screens. State libraries are easy to overuse in RN.

    iOS (SwiftUI)

    • UI: View + ObservableObject / @StateObject
    • Domain: pure Swift structs + use-case types
    • Data: URLSession + persistence (CoreData/SQLite/File)

    A practical SwiftUI tip: make your ViewModel the UI boundary and keep domain logic out.

    Android (Compose)

    • UI: Composables + ViewModel
    • Domain: use-cases + domain models
    • Data: repository + Retrofit/Room

    Your ViewModel can call use-cases and expose state as StateFlow.

    Common anti-patterns (and what to do instead)

    Anti-pattern 1: “God service”

    Symptom:

    • ApiService has 40 methods
    • AppRepository becomes a dumping ground

    Fix:

    • split by feature or domain concept (AuthRepoTodoRepoBillingRepo)
    • keep interfaces small and cohesive

    Anti-pattern 2: Deep inheritance trees

    Inheritance makes changes hard because behavior is spread across classes.

    Fix:

    • use composition
    • pass small collaborators into classes

    Anti-pattern 3: Premature modularization

    Breaking your app into many modules early creates friction: build times, wiring, and navigation across modules.

    Fix:

    • start with folders
    • extract modules only when boundaries are repeatedly painful (or compile times demand it)

    Anti-pattern 4: “Architecture by library”

    If your architecture requires a specific library to exist, it’s fragile.

    Fix:

    • define your own boundaries (interfaces + use-case API)
    • treat libraries as replaceable implementation details

    A simple migration path when the app grows

    This pattern scales surprisingly far. Still, you’ll eventually hit a few thresholds.

    When to split into modules

    Consider extracting modules when:

    • build times get noticeably slow
    • features are mostly independent
    • you’ll onboard another dev

    Before that, folders are fine.

    When to add more architectural structure

    Add complexity only when you can name the pain.

    Examples:

    • offline-first requirements → add a local data source and sync rules
    • multiple environments/tenants → introduce configuration boundaries
    • heavy business logic → split domain into feature domains (OrdersDomainBillingDomain)

    What to keep the same

    Even if you evolve toward “clean architecture,” keep the small soul of this pattern:

    • explicit boundaries
    • stable domain model
    • data details don’t leak

    Your minimal architecture checklist

    If you want a practical “am I doing this right?” list, use this:

    • UI calls use-cases, not repositories.
    • Domain types contain no framework imports.
    • Data layer is the only place with DTOs.
    • Repositories hide caching/offline logic from UI.
    • Every screen has an explicit state model (loading/empty/content/error).
    • You can test a use-case without network.
    • Folder structure makes it obvious where things go.

    If you can check these boxes, you’re not over-engineering.

    FAQ

    “Isn’t this basically Clean Architecture?”

    It overlaps, but it’s intentionally smaller.

    Clean Architecture can turn into a lot of ceremony: entities, interactors, presenters, gateways, multiple models per layer.

    This pattern keeps the parts that create leverage for a solo dev:

    • use-cases for business logic
    • repos as boundaries
    • DTO mapping

    …and skips the rest until you need it.

    “What about dependency injection?”

    Use DI only as much as you need.

    • In small apps: manual wiring is fine.
    • In medium apps: a lightweight DI mechanism (or provider system) is fine.

    The key is that DI is a wiring tool, not the architecture.

    “What if I’m building both iOS and Android?”

    This pattern helps more when you’re multi-platform, because it gives you a consistent mental model.

    Even if you don’t share code, sharing the shape of the architecture reduces context switching.

    “What if my domain logic is tiny?”

    Then keep it tiny.

    A domain layer can start as:

    • domain/models
    • domain/usecases

    Five files total is still a domain layer.

    Next actions (do this in the next 60 minutes)

    1. Pick one feature in your app (login, list, checkout).
    2. Draw three boxes: UI → Domain → Data.
    3. Move one business rule from UI into a use-case.
    4. Add one repository interface and one fake implementation.
    5. Ship a small improvement end-to-end.

    Minimal architecture isn’t a refactor project. It’s a way of working.

    Spread the love
  • Crash‑Free at Scale: Symbolication, DWARF, and Real‑Time Triage with Metrics

    Crash‑Free at Scale: Symbolication, DWARF, and Real‑Time Triage with Metrics

    Delivering a crash‑free experience across thousands or millions of devices requires more than plugging in a crash SDK. You need accurate symbolication, stable build artifacts, and real‑time metrics that highlight regressions before reviews or social media do.

    This guide walks through a practical, founder‑grade setup:

    • Build settings for DWARF & dSYM accuracy
    • CI workflows to archive symbols and map builds to releases
    • On‑device breadcrumbs and session context to speed triage
    • Server‑side symbolication pipeline with examples
    • Metric dashboards (crash‑free %, cohorts, release diffs)

    We’ll use stock Xcode + command‑line tools, with example pipelines for Sentry, Firebase Crashlytics, and a custom symbolication service.


    Goals

    • Maximize “crash‑free users” and “crash‑free sessions” for each release
    • Ensure all crashes symbolicate (no “unknown” frames) across arm64 and simulators
    • Make regressions visible within minutes of rollout
    • Keep artifact management boring and reliable in CI

    Key Concepts

    • dSYM: Debug Symbols archive containing DWARF info used to map addresses → function names + line numbers.
    • DWARF: Debugging format embedded in binaries or dSYMs; required for readable stack traces.
    • UUID (Build UUID/UUIDs): Unique identifiers per Mach‑O slice used to match crash reports to the correct dSYM.
    • Symbolication: Translating raw addresses from crash logs into human‑readable stack frames.
    • Crash‑free rate: 100 × (1 − crashes / sessions) or users without crashes ÷ total users.

    Xcode Build Settings for Reliable Symbols

    In your target build settings:

    • “Debug Information Format” (DEBUG_INFORMATION_FORMAT):
      • Debug: DWARF
      • Release: DWARF with dSYM File
    • “Strip Debug Symbols During Copy”: Yes
    • “Strip Linked Product”: Yes (but ensure dSYM generation remains enabled)
    • “Generate Debug Symbols”: Yes

    Example xcconfig snippet:

    // Configs/Release.xcconfig
    DEBUG_INFORMATION_FORMAT = dwarf-with-dsym
    STRIP_INSTALLED_PRODUCT = YES
    STRIP_STYLE = all
    GCC_GENERATE_DEBUGGING_SYMBOLS = YES
    ENABLE_BITCODE = NO // Prefer NO to avoid mismatched UUIDs on App Store recompile

    Why ENABLE_BITCODE = NO? When bitcode is enabled, Apple may re‑compile your binary, producing different UUIDs. If your crash backend doesn’t fetch App Store recompiled dSYMs automatically, symbolication can fail. Many teams now disable bitcode for consistent UUID mapping.


    Exporting and Archiving dSYMs in CI

    Regardless of vendor, keep dSYMs under versioned storage keyed by release name and build number.

    Example GitHub Actions step (Fastlane optional):

    name: iOS Build & dSYM Upload
    on:
      workflow_dispatch:
      push:
        branches: [main]
    
    jobs:
      build:
        runs-on: macos-13
        steps:
          - uses: actions/checkout@v4
          - name: Xcode archive
            run: |
              xcodebuild \
                -workspace App.xcworkspace \
                -scheme App \
                -configuration Release \
                -sdk iphoneos \
                -archivePath build/App.xcarchive \
                clean archive
          - name: Export IPA + dSYMs
            run: |
              xcodebuild -exportArchive \
                -archivePath build/App.xcarchive \
                -exportPath build/export \
                -exportOptionsPlist ExportOptions.plist
          - name: Find dSYMs
            run: |
              find build/export -name "*.dSYM" -print
          - name: Upload dSYMs to Sentry
            env:
              SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
              SENTRY_ORG: your-org
              SENTRY_PROJECT: ios-app
            run: |
              curl -sL https://sentry.io/get-cli/ | bash
              sentry-cli releases new $GITHUB_SHA
              sentry-cli releases set-commits --auto $GITHUB_SHA
              sentry-cli upload-dsym build/export
              sentry-cli releases finalize $GITHUB_SHA

    Crashlytics upload example:

    # Using Firebase Crashlytics upload-symbols script
    bash "Pods/FirebaseCrashlytics/upload-symbols" -gsp "GoogleService-Info.plist" -p ios build/export

    Custom storage (S3, GCS) with UUID manifest:

    # Extract UUIDs and upload manifest
    dwarfdump --uuid build/export/App.app/App | tee build/uuid.txt
    aws s3 cp build/uuid.txt s3://symbols/app/1.2.3/uuid.txt
    aws s3 sync build/export/*.dSYM s3://symbols/app/1.2.3/dsyms/

    Reading UUIDs Locally

    You can check which UUIDs your app contains per architecture:

    dwarfdump --uuid MyApp.app/MyApp
    # Example output:
    # UUID: 12345678-ABCD-1234-ABCD-1234567890AB (arm64) MyApp.app/MyApp

    Crashes must reference one of these UUIDs. If the UUID in the crash doesn’t match your dSYM’s UUID, symbolication won’t work.


    On‑Device Breadcrumbs and Session Context

    Crash logs become actionable when paired with app state:

    • Active screen / feature flag / AB cohort
    • Previous screen + elapsed time
    • Network status, CPU, memory pressure
    • User plan/tier (if applicable)

    Lightweight Swift example:

    import Foundation
    
    struct Breadcrumb: Codable {
        let ts: Date
        let message: String
        let attributes: [String: String]
    }
    
    final class Breadcrumbs {
        static let shared = Breadcrumbs()
        private var store: [Breadcrumb] = []
        private let queue = DispatchQueue(label: "breadcrumbs.queue")
    
        func add(_ message: String, attributes: [String: String] = [:]) {
            queue.async {
                self.store.append(Breadcrumb(ts: Date(), message: message, attributes: attributes))
                if self.store.count > 200 { self.store.removeFirst(self.store.count - 200) }
            }
        }
    
        func exportJSON() -> Data? {
            queue.sync { try? JSONEncoder().encode(store) }
        }
    }
    
    // Usage: tie into navigation and key events
    Breadcrumbs.shared.add("Opened Screen", attributes: ["screen": "Settings"])
    Breadcrumbs.shared.add("Tapped", attributes: ["button": "Save"])

    On crash, many SDKs allow attaching custom data. For a custom flow, persist breadcrumbs regularly:

    func persistBreadcrumbs() {
        guard let data = Breadcrumbs.shared.exportJSON() else { return }
        let url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("breadcrumbs.json")
        try? data.write(to: url)
    }

    Server‑Side Symbolication (DIY)

    If you collect raw crash reports (Mach exception addresses or PLCrashReporter format), you can symbolize server‑side.

    High‑level steps:

    1. Extract crash binary UUID(s) from the report
    2. Locate matching dSYM by UUID or release
    3. Use atos or llvm-addr2line with DWARF to map addresses → symbols
    4. Render stack frames with file:line

    Example: symbolize with atos on macOS runner:

    # Inputs
    APP_BIN="MyApp.app/MyApp"
    DSYM_BIN="MyApp.app.dSYM/Contents/Resources/DWARF/MyApp"
    ADDRESS="0x0000000100a3b9dc" # address from crash frame
    UUIDS=$(dwarfdump --uuid "$APP_BIN")
    
    echo "Binary UUIDs:\n$UUIDS"
    atos -o "$APP_BIN" -arch arm64 "$ADDRESS"
    # Or using dSYM directly: atos -o "$DSYM_BIN" -arch arm64 "$ADDRESS"

    Using llvm-addr2line (Homebrew llvm):

    brew install llvm
    /opt/homebrew/opt/llvm/bin/llvm-addr2line -e "${DSYM_BIN}" -f -C 0x0000000100a3b9dc

    Batch symbolication example (Node.js):

    // tools/symbolicate.js
    const { execSync } = require("child_process");
    const path = require("path");
    
    function symbolize(addresses, dSYMPath) {
      return addresses.map((addr) => {
        const out = execSync(
          `/opt/homebrew/opt/llvm/bin/llvm-addr2line -e "${dSYMPath}" -f -C ${addr}`,
        );
        return out.toString();
      });
    }
    
    const dSYM = process.argv[2];
    const addresses = process.argv.slice(3);
    console.log(symbolize(addresses, dSYM).join("\n"));

    Run:

    node tools/symbolicate.js MyApp.app.dSYM/Contents/Resources/DWARF/MyApp 0x100a3b9dc 0x100a3c120

    Release Mapping and Cohort Metrics

    Tie crashes back to rollouts and user cohorts:

    • Build → Release channel (TestFlight, phased, forced)
    • Country, device class, OS version
    • New vs. returning users

    Schema example (SQL):

    CREATE TABLE releases (
      id SERIAL PRIMARY KEY,
      version TEXT NOT NULL,
      build TEXT NOT NULL,
      commit_sha TEXT,
      created_at TIMESTAMPTZ DEFAULT NOW()
    );
    
    CREATE TABLE crash_events (
      id BIGSERIAL PRIMARY KEY,
      release_id INTEGER REFERENCES releases(id),
      uuid TEXT, -- binary UUID
      os TEXT,
      device TEXT,
      address TEXT,
      symbol TEXT,
      file TEXT,
      line INTEGER,
      user_id TEXT,
      session_id TEXT,
      country TEXT,
      ts TIMESTAMPTZ DEFAULT NOW()
    );
    
    CREATE INDEX ON crash_events (release_id);
    CREATE INDEX ON crash_events (uuid);

    Crash‑free metrics:

    -- Crash-free sessions per release (24h)
    SELECT r.version, r.build,
      1.0 - (SUM(CASE WHEN c.session_id IS NOT NULL THEN 1 ELSE 0 END)::float
            / GREATEST(COUNT(DISTINCT s.session_id), 1)) AS crash_free_rate
    FROM releases r
    JOIN sessions s ON s.release_id = r.id AND s.ts > NOW() - INTERVAL '24 hours'
    LEFT JOIN crash_events c ON c.session_id = s.session_id AND c.ts > NOW() - INTERVAL '24 hours'
    GROUP BY r.version, r.build
    ORDER BY r.created_at DESC;

    Real‑Time Triage: Alerts and Dashboards

    • Alert on crash‑free drop > X% in Y minutes after rollout
    • Alert on new top‑crash signature (hash of top 5 frames)
    • Dashboard cards: crash‑free users/sessions, release diffs, top devices/OS, worst screens

    Example alert logic (pseudo):

    # triage/alerts.py
    from datetime import datetime, timedelta
    
    THRESHOLD_DROP = 0.03  # 3% drop
    WINDOW_MINUTES = 30
    
    # get_crash_free(release, window) -> float
    # get_baseline(release) -> float, previous 7d mean
    
    def should_alert(release):
        now = datetime.utcnow()
        window = timedelta(minutes=WINDOW_MINUTES)
        current = get_crash_free(release, window)
        baseline = get_baseline(release)
        return (baseline - current) >= THRESHOLD_DROP

    Integrations: Crashlytics and Sentry Cheats

    Crashlytics:

    • Ensure upload-symbols runs for every distribution (Debug/TestFlight/App Store)
    • Turn on “automatic dSYM download” if using App Store recompiled binaries
    • Use CLS_LOG or custom keys for breadcrumbs

    Sentry:

    • Use Releases and Commits to correlate deploys
    • Upload dSYMs via CI with sentry-cli
    • Use “Grouping” and “Fingerprint” to tune unique crash signatures

    Checklist for Crash‑Free at Scale

    • [ ] Release: DWARF with dSYM, Bitcode disabled or handled
    • [ ] CI: Archive + upload dSYMs per release, store UUID manifest
    • [ ] App: Breadcrumbs with screen + action + environment
    • [ ] Backend: Symbolication pipeline (atos/addr2line) by UUID
    • [ ] Metrics: Crash‑free users/sessions, alerting on drops and new signatures

    Appendix: Reading Apple Crash Logs Locally

    Given a .crash log, verify UUID and symbolize a frame:

    grep -E "UUID|Binary Images" MyCrash.crash
    # Identify the app binary UUID and the address from Thread X crashing frame.
    
    atos -o MyApp.app/MyApp -arch arm64 0x0000000100a3b9dc

    If you only have the dSYM:

    atos -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -arch arm64 0x0000000100a3b9dc

    Final Notes

    Prioritize accuracy and boring reliability in artifacts. Make symbol uploads automatic, tie metrics to releases and cohorts, and keep actionable breadcrumbs. With this setup, you’ll find and fix crash spikes quickly — preserving user trust and App Store momentum.

    Spread the love
  • Swift 6 Deep Dive: Concurrency, Ownership, and Performance in Real Apps

    Swift 6 Deep Dive: Concurrency, Ownership, and Performance in Real Apps

    Table of Contents

    • Objective and Success Metrics
    • The Swift 6 Landscape
    • Structured Concurrency: async/await, Task, TaskGroup
    • Actor Isolation: Shared Mutable State (without over-actorizing)
    • Ownership and Sendable: Value Types, Isolation, Intent
    • SwiftUI and MainActor Boundaries
    • Backpressure and Cancellation
    • Networking Reliability: Retry, Timeout, Priority
    • Performance Budgets and Instruments
    • Storage: SwiftData/SQLite Patterns for Production
    • Navigation and State: Router Patterns in SwiftUI
    • Interop: Combine, AsyncStream, and Observable
    • Background Work: Reliability in a Post-Fetch World
    • Security and Privacy: Modern Defaults
    • Implementation Checklist
    • FAQs

    Objective and Success Metrics

    Build faster, safer, more reliable iOS/macOS apps using Swift 6’s concurrency and ownership model.

    • Crash-free sessions: improve stability and reduce heisenbugs
    • Performance: lower latency, fewer context switches, smoother UI
    • Predictability: explicit boundaries, lifetimes, and cancellation

    The Swift 6 Landscape

    Swift 6 strengthens structured concurrency and compiler diagnostics:

    • Stricter Sendable checks and improved isolation
    • Ownership semantics that reduce copies and clarify intent
    • Runtime scheduling and standard library performance work

    Structured Concurrency: async/await, Task, TaskGroup

    Use async/await for straightforward flows; TaskGroup for aggregation and coordinated cancellation.

    func loadDashboard(userID: String) async throws -> DashboardData {
        try await withThrowingTaskGroup(of: Any.self) { group in
            group.addTask { try await fetchProfile(userID) }
            group.addTask { try await fetchStats(userID) }
            group.addTask { try await fetchFeed(userID) }
    
            var profile: Profile?
            var stats: Stats?
            var feed: [Item] = []
    
            for try await result in group {
                switch result {
                case let p as Profile: profile = p
                case let s as Stats: stats = s
                case let f as [Item]: feed = f
                default: break
                }
            }
            guard let p = profile, let s = stats else { throw AppError.missingData }
            return DashboardData(profile: p, stats: s, feed: feed)
        }
    }

    Guidelines

    • Keep groups shallow; prefer clarity over cleverness
    • Propagate errors; don’t swallow and log later
    • Prefer structured lifetimes; avoid Task.detached for app logic

    Actor Isolation: Shared Mutable State (without over-actorizing)

    Actorize the mutable core — caches, session state, in-memory stores — and keep computation outside.

    actor ImageCache {
        private var store: [URL: Image] = [:]
        func put(_ url: URL, image: Image) { store[url] = image }
        func get(_ url: URL) -> Image? { store[url] }
        nonisolated func estimateCount() -> Int { 0 }
    }

    Principles

    • Isolate mutation; permit pure reads via nonisolated when safe
    • Avoid actorizing everything; measure throughput and latency
    • Centralize cancellation in your pipeline

    Ownership and Sendable: Value Types, Isolation, Intent

    Sendable annotations and ownership semantics eliminate cross-thread mutation bugs.

    struct Profile: Sendable { /* fields */ }
    final class Session: @unchecked Sendable { /* document thread usage */ }

    Rules

    • Prefer structs for cross-task data
    • Confine class mutation to actors or MainActor
    • Reach for @unchecked Sendable only with explicit rules

    SwiftUI and MainActor Boundaries

    UI mutations belong on MainActor; async work does not.

    @MainActor
    final class DashboardViewModel: ObservableObject {
        @Published private(set) var items: [Item] = []
        @Published private(set) var isLoading = false
    
        func refresh() {
            isLoading = true
            Task {
                let fetched = try await fetchItems()
                await MainActor.run { self.items = fetched; self.isLoading = false }
            }
        }
    }

    Backpressure and Cancellation

    Unbounded fans burn battery and starve UI. Add gates and cancellations.

    actor RequestGate {
        private let maxConcurrent: Int
        private var running = 0
        private var waiters: [CheckedContinuation<Void, Never>] = []
    
        init(maxConcurrent: Int = 6) { self.maxConcurrent = maxConcurrent }
    
        func acquire() async {
            if running < maxConcurrent { running += 1; return }
            await withCheckedContinuation { cont in waiters.append(cont) }
        }
    
        func release() {
            if let next = waiters.popLast() { next.resume() } else { running = max(0, running - 1) }
        }
    }

    Checklist

    • Link task lifetimes to view lifetimes
    • Cancel on route changes; dedupe by ID
    • Coalesce repeated requests

    Networking Reliability: Retry, Timeout, Priority

    Keep the network layer predictable.

    struct RequestOptions { let priority: TaskPriority; let timeout: TimeInterval; let retries: Int }
    
    func fetchJSON<T: Decodable>(from url: URL, options: RequestOptions = .init(priority: .medium, timeout: 15, retries: 2)) async throws -> T {
        return try await withTimeout(seconds: options.timeout) {
            try await retry(times: options.retries, jitter: 0.3) {
                let (data, _) = try await URLSession.shared.data(from: url)
                return try JSONDecoder().decode(T.self, from: data)
            }
        }
    }

    Performance Budgets and Instruments

    Measure and enforce budgets.

    • Concurrency caps per screen
    • Memory caps (vector length, cache size)
    • Energy caps (no long tasks on input)
    • Instruments: Energy, Time Profiler, Allocations, Concurrency; signpost hot paths
    import os.signpost
    let log = OSLog(subsystem: "com.app", category: "perf")
    let sp = OSSignposter(log: log)
    func signposted<T>(_ name: StaticString, _ op: () async throws -> T) async rethrows -> T {
        let s = sp.beginInterval(name); defer { sp.endInterval(name, s) }
        return try await op()
    }

    Storage: SwiftData/SQLite Patterns for Production

    • Plan migrations; don’t rely on magic
    • Batch operations; avoid per-row loops
    • Avoid N+1 queries; index and measure faulting

    Navigation and State: Router Patterns in SwiftUI

    • Central Router that owns navigation state
    • Dependencies via Environment for testability
    • Avoid global singletons that keep tasks alive

    Interop: Combine, AsyncStream, and Observable

    Bridge pragmatically; don’t rewrite happy code.

    func combineToAsync<T>(_ publisher: some Publisher<T, Error>) -> AsyncThrowingStream<T, Error> {
        AsyncThrowingStream { continuation in
            let c = publisher.sink(receiveCompletion: { completion in
                if case .failure(let e) = completion { continuation.finish(throwing: e) } else { continuation.finish() }
            }, receiveValue: { value in continuation.yield(value) })
            continuation.onTermination = { _ in c.cancel() }
        }
    }

    Background Work: Reliability in a Post-Fetch World

    • Idempotent, resumable tasks
    • Short, progressive units; re-schedule if needed
    • Checkpoints for long operations

    Security and Privacy: Modern Defaults

    • Passkeys first, passwords fallback
    • DeviceCheck for abuse signals
    • Private Relay-aware networking

    Implementation Checklist

    • [ ] Define budgets (concurrency, memory, energy)
    • [ ] Actorize shared mutable state; keep computation outside
    • [ ] Adopt async/await and TaskGroup for structured flows
    • [ ] Implement RequestGate and cancellation patterns
    • [ ] Predictable networking (retry, timeout, priority)
    • [ ] Add signposts; profile with Instruments
    • [ ] Plan storage migrations; batch writes
    • [ ] Router-driven navigation; avoid global singletons
    • [ ] Pragmatic interop: Combine ↔︎ AsyncStream; prefer Observable for new state
    • [ ] Background reliability with checkpoints

    FAQs

    • Do I need to rewrite my app for Swift 6?
      • No. Incrementally adopt structured concurrency and actors where it pays off.
    • Should I actorize all classes?
      • No. Isolate shared mutable state; keep pure computation outside actors.
    • Is Task.detached bad?
      • Use it sparingly. Prefer structured lifetimes and cancellation with Task/TaskGroup.
    • How do I fix flaky concurrency tests?
      • Test actors and isolated state directly; avoid wall-clock sleeps; add hooks.
    • What’s the fastest way to see performance problems?
      • Instruments + signposts; measure before guessing.

    Spread the love