Case study · Fintech

Saraf

A crypto- and gold-asset trading app for the Iranian market (with a parallel Arabic-market guest flow), rebuilt underneath its users onto a modular MVVM-C architecture: 25 feature modules, a hand-built DI system, and 534 tests, migrating one flow at a time while the legacy app keeps compiling alongside it.

Role
Senior iOS Engineer — parallel with BankID
Platform
iOS (iPhone) · iOS 15+, WidgetKit iOS 14+
Stack
Swift · SwiftUI · Combine + Swift Concurrency · CocoaPods + SPM
Scale
622 commits · 514 new-architecture files · 25 feature modules
Overview

One trading app, rebuilt underneath its users.

Saraf is an iOS app for buying, holding, and selling crypto and gold-backed assets priced in Toman: KYC-gated trading, bank and card management, and a home-screen price widget. A parallel Arabic-market and guest flow, AlSaraf, extends the same app to a second market and currency without a separate build target.

I joined in May 2025 as Senior iOS Engineer, working the role in parallel with a concurrent BankID engagement, to lead the app's move off a single-file legacy architecture and onto a modular one, without freezing feature work to do it. I was promoted within the first year and received an internal "Outstanding Performance" recognition for the modernization.

The problem

Saraf's original architecture put everything in one place: a 370-line AppManagerViewModel (a single static let shared singleton, explicitly named "Legacy" by the team) carried login status, sheet-visibility flags, the selected tab, a splash-loading flag, and a manual reloadID used to force view refreshes, all as one ObservableObject imported wherever a screen needed any of it. Around that sat 226 Swift files in a flat Model/View/ViewModel split, still growing by commit.

There was no freeze window to rebuild it in. Saraf was a live, KYC-gated trading app; it had to keep shipping and keep working for existing users while its architecture changed underneath them. The new Core/ and Features/ trees were built alongside the legacy code instead of replacing it. That's a strangler-fig migration, not a rewrite.

Constraints

  • iOS 15 minimum, with the widget one version further back. The SarafWidget WidgetKit extension targets iOS 14, a point below the main app's floor, which limits what the widget can assume about the platform.
  • KYC gates access to trading, a further check beyond onboarding. KYCL1UseCase cross-checks a national ID and birth date against the backend, with dedicated error branches for a code/birthdate mismatch and a code/phone mismatch. Legal-name fields accept Persian and Arabic script only, with digits explicitly rejected. Trade access sits behind this tier.
  • Language and layout direction are resolved, not chosen. A hybrid detector falls back from user preference to device timezone: Tehran resolves to Persian, Dubai to Arabic, anything else to English. RTL/LTR layout switches along with it.
  • A ten-second price budget, by design. Live market pricing runs on client-side polling, not a push channel. The reasoning is in Architecture, below.
  • Two markets, one codebase. The main Persian-market flow and the Arabic/guest AlSaraf flow duplicate real surface area: login, home, portfolio, coin details. All of it has to stay behaviorally consistent as both keep changing.
  • Six analytics SDKs, running at once. WebEngage, Adtrace, Sentry, Microsoft Clarity, Heap, and Firebase Analytics all run simultaneously. That's a real cost against binary size and startup time, not something to treat as free.
Architecture

Twenty-five modules, one service locator.

MVVM-C with a Repository + Use Case domain layer. Core/ holds the shared plumbing (Coordinators, DI, Data, Domain, Services), and Features/ holds 25 self-contained modules, each with its own Coordinator, ViewModels, and Views. The legacy "Saraf App" tree still compiles alongside all of it while migration continues, flow by flow.

Saraf architecture: SwiftUI views and per-feature Coordinators call through Use Cases and a 24-repository data layer resolved by a hand-built Service Locator; PricePollingService polls the backend REST API every ten seconds for live prices, while a legacy 226-file "Saraf App" tree and a WidgetKit extension continue alongside it. FEATURES · 25 MODULES SwiftUI View Declarative screen render Feature ViewModel Screen state + intent handling Coordinator One per feature · 23 protocols SarafWidget WidgetKit extension · iOS 14+ Legacy "Saraf App" 226 files, still compiled CORE ARCHITECTURE ServiceLocator DI facade · resolve() at construction Use Case layer Single-purpose business logic Repository (24×) API-to-domain translation PricePollingService 10s Task loop, lifecycle-aware — not push NetworkService URLSession async/await · typed APIEndpoint BACKEND (EXTERNAL) Backend REST API JSON over HTTPS · region-keyed routing Socket.IO channel Tehran-gated push notifications in-process call HTTPS / Socket.IO
PricePollingService (not a push channel) is what actually feeds live prices to the Market screen today; the codebase's one WebSocket client is kept for Tehran-gated login and session notifications, not market data. SarafWidget talks to the backend directly over its own URLSession, bypassing Core and the Repository layer entirely. The legacy "Saraf App" tree (226 files) still compiles alongside all of it, a strangler-fig migration in progress rather than a finished rewrite.

Viewing the market runs top to bottom through the stack: a MarketViewModel, resolved through the Service Locator, subscribes to PricePollingService's Combine publisher, which itself runs a ten-second Task loop calling GetListedPricesUseCase (guarded by an NSLock, paused and resumed across foreground/background transitions) through MarketRepository, through NetworkService, against a typed APIEndpoint. Tapping an asset routes into the PDP (Product Detail Page) feature; tapping Buy there runs its own quote-refresh timer and validates the entered amount before the primary action enables. If the account isn't yet L1-verified, the flow redirects into an auth-pending or auth-declined sheet instead of a trade confirmation.

Engineering decisions

Decisions made mid-flight.

01

Repository + Use Case, with a Coordinator per feature

I introduced a protocol-only Core/Domain layer and a concrete Core/Data layer, plus one navigation Coordinator per feature, replacing the flat View/ViewModel/Model split in the legacy tree instead of letting AppManagerViewModel-style shared singletons keep growing.

02

Native URLSession/async-await over Alamofire, for new code only

NetworkService is built on plain URLSession with async/await, a typed APIEndpoint enum, and centralized HTTP-status-to-error mapping. Alamofire stays in the Podfile for the legacy code that still depends on it: 41 files still import it, and every one sits under the legacy tree, never under Core/ or Features/.

03

A documented Service Locator over pure constructor injection

ServiceLocator.shared wraps a DependencyContainer with singleton and transient lifetimes and circular-dependency detection, documented in a checked-in DI_DOCUMENTATION.md. Pure initializer injection was the alternative, and it would have meant touching all 25 feature modules' construction sites at once instead of migrating them incrementally.

04

Strangler-fig migration, not a rewrite

The legacy 226-file tree keeps compiling and shipping while Core/ and Features/ (514 files) are built in parallel, migrating one flow at a time: Login, then Home, then the bank-account flows. A freeze-and-rewrite of a live trading app was never on the table.

05

Client-side polling for live market data, not a push channel

PricePollingService runs a lifecycle-aware Task loop fetching listed prices every ten seconds; the Market screen subscribes to its Combine publisher rather than a dedicated socket. A ten-second interval is a defensible budget for a mobile trading UI where the actual trade already round-trips over REST at execution time, and the price gets re-verified server-side regardless of how fresh the display number is. Standing up a dedicated WebSocket price feed wasn't worth it: the one WebSocket client in the codebase stays reserved for Tehran-gated login and session notifications, not market data.

06

Guest flow extended the existing Coordinator tree, not a new target

The international/guest base-currency picker, ExchangeRateStore, and tokenless guest endpoints extend ServicesCoordinator and PDP rather than forming a separate module, so a fully separate guest-only build target never got built.

Trade-offs

What it cost, on purpose.

Every architecture decision here was made against a codebase that couldn't stop shipping. These are the prices that came with that.

Testable business logic across the rewrite

24 repository protocols with matching mocks back 534 test functions, coverage a global ObservableObject singleton never had a seam for.

The cost

Two networking and DI patterns have to be reasoned about simultaneously until legacy is fully retired: Alamofire plus ObservableObject singletons in legacy, URLSession async plus ServiceLocator in new.

A documented Service Locator over constructor injection

A DI container with circular-dependency detection, health checks, and its own nine-benchmark performance-test suite, with a written usage guide checked into the repo.

The cost

It's a service locator, not compile-time constructor injection (DI_DOCUMENTATION.md names this explicitly as the chosen trade-off), and a handful of resolution failures surface as fatalError() rather than a build-time type error.

Twenty-five independently ownable modules

Each feature module owns its own Coordinator, ViewModels, and Views: Buy can change without touching Login.

The cost

Some modules are thin re-wraps of very little (SarafCard is one file, Stepper is two); module boundaries got drawn ahead of the content that would justify them in a few places.

One design system, two markets

Shared colors, six font families spanning Persian, Arabic, and Latin scripts, and seventeen shared UI components serve both the main Persian-market flow and the Arabic-market AlSaraf flow.

The cost

AlSaraf still duplicates real screen logic: its own Coordinator, Login, Home, Portfolio, and coin-detail ViewModels, rather than sharing the main flow's. Two flows have to be kept behaviorally consistent by hand.

Technical challenges

The parts that fought back.

A region-gated real-time channel, straddling two auth models

SocketIOService.connect() only opens its socket when the device timezone is Asia/Tehran, and it still reads its session token from a legacy global static rather than the new DI-managed auth state. It's a real seam between the old auth model and the new one, and it isn't closed yet.

Backend-specific error mapping, one key at a time

NetworkService handles HTTP 400 through 5xx individually and decodes a shared error DTO carrying both a localized message and an error key. Call sites like KYCL1UseCase switch on that key to surface a specific mismatch (a code/birthdate error reads differently to the user than a code/phone error) instead of one generic failure.

Correctness of a Task-based polling loop across app lifecycle

PricePollingService guards start() and stop() with an NSLock and explicitly listens for foreground and background notifications to cancel and relaunch its polling Task. A naive Task-loop poller's real failure mode is orphaned loops or duplicate timers across suspend and resume, and this is what avoids it.

Coexistence bugs, surfacing mid-migration

During the international/guest-currency rollout, guest and authenticated state weren't threaded consistently through the newer Coordinator navigation. A missing argument in PDP navigation and an unhidden sell button for guests both shipped, and both were caught and fixed the same day.

Performance & reliability

What the rewrite actually built.

534
Test functions
Across 102 test files: DTO mapping, repositories, use cases, and a dedicated DI suite with its own performance benchmarks. Run locally; no CI pipeline is configured in the repo.
25
Feature modules
Each with its own Coordinator, ViewModels, and Views, from one-file modules like SarafCard to eighteen-file ones like Profile.
514
New-architecture files
Core/ + Features/, built in parallel with the 226-file legacy tree while both still compile into the same target.
Security

Identity and money, checked server-side first.

Saraf is a trading app tied to real KYC documents and bank accounts. The security model centers on who's allowed to trade and how session state is handled, more than on cryptography for its own sake:

  • Auth and session → Keychain. A dedicated Keychain wrapper, with its own unit tests, replaced the pattern of keeping a session token in a global static string used directly in HTTP headers. That pattern still exists in a few unmigrated legacy code paths.
  • OTP → backend-driven, not client-timer-only. Dedicated delivery-status and verification-result entities, plus a resend flow, reflect the backend's actual OTP delivery status rather than trusting a client-side countdown.
  • KYC (L1) → server-side identity check, client-side format validation only. KYCL1UseCase sends national ID and birth date to the backend for matching (no identity verification happens on-device), while legal-name fields are restricted to Persian/Arabic script, with digits rejected before the request is ever sent. Trade access gates on L1 status.
  • Transport. REST over HTTPS, through an ephemeral URLSessionConfiguration with no on-disk URL cache, reducing residue of API responses left on the device.

Because this is a live trading app, deeper details on token handling and the legacy card/address-verification flow are intentionally left out here.

Engineering journal

From one singleton to twenty-five modules.

2024 · Origin

Saraf starts as a single-target app

Three near-duplicate root commits mark the actual start of the repo: a single-target SwiftUI app with a flat View/ViewModel/Model structure, well before my involvement.

2025-05 · Start

Join on the widget, in parallel with BankID

My first commit opens the WidgetKit extension work. From here the role runs alongside a concurrent BankID engagement.

2025-06 · Decision

Keychain-based auth lands

A dedicated Keychain wrapper, with its own test file, ships ahead of the wider architecture rewrite: the first piece of the new auth model.

2025-08 → 2025-10 · Migration

Core architecture, repositories, and Coordinators land

Network-service and API-endpoint infrastructure ships first, then the Core/Domain and Core/Data split with the first Repository and Use Case pairs, then the Coordinator-per-feature pattern is formalized and a dedicated test target is created.

2026-01 · Mistake/Refactor

AlSaraf pulled back out, the same day

The Arabic-market flow gets detached from the main app Coordinator the same day new DI/Coordinator scaffolding ships, a live correction mid-rework rather than a design that held on the first attempt.

2026-06 · Incident, same-day fix

Guest rollout surfaces a real bug, caught fast

During an eight-commit day shipping the international/guest-currency flow, guest state wasn't threaded consistently through PDP navigation; it was found and fixed within hours instead of sitting for the next sprint.

2026 · Ongoing

Promoted, recognized, still migrating

Promoted within the first year in the role and recognized with an internal "Outstanding Performance" distinction. The legacy tree is smaller than it was, not gone; the migration continues flow by flow.

Lessons learned

What I'd tell myself at the start.

  • Migrating architecture underneath a shipping app works as a strangler-fig, not a freeze-and-rewrite. The legacy tree still compiles 600-plus commits into the effort, and that's fine: it's being replaced on a schedule, not overnight.
  • A documented DI system pays for itself once module count passes roughly a dozen. DI_DOCUMENTATION.md is the one part of the new architecture with its own written usage guide and dedicated performance-test suite, worth the up-front cost at 25 modules.
  • Bolting a second product surface onto an in-progress migration multiplies the seams that break. The guest/Arabic-market rollout produced same-day fixes because the Coordinator-navigation contract wasn't fully guest-aware yet when that work started.
Outcome

A migration in its final stretch, not a finished one.

Saraf's repo shows continuous activity through mid-2026, with June as the busiest month in the project's history. A UI-Finished tag lands in June, and the app's version currently reads as a from-scratch 1.0, consistent with a relaunch rather than an incrementing release. The legacy tree is still there, still compiling, and still shrinking one migrated flow at a time.

The work runs in parallel with BankID: I was promoted within the first year on Saraf and received an internal "Outstanding Performance" recognition for moving a live, KYC-gated trading app onto a modular architecture without a rewrite freeze.