top of page

01 · INTRODUCTION

How to Spot a Frankenstein Architecture in iOS

LegacyLaunchManager.swift · abridged from the lab

func refresh(completion: @escaping @MainActor () -> Void) {
    DispatchQueue.main.async {
        Task { @MainActor in
            do {
                let result = try await self.transport.downloadLaunch()
                self.launch = result
            } catch {
                self.launch = nil
            }
        }
    }
    completion()
}

Spot the seam: completion() runs before the queued download begins. The Task handle is discarded, and every response is allowed to publish. Only the lab’s logging, publication instrumentation and comments are omitted here.

Start with the code. Then follow the ownership, lifetime and result-ordering decisions it leaves unanswered.

The project used to dispatch work onto queues. Now it creates Tasks. The folders are familiar, the managers are familiar, and the call paths are almost unchanged. Somewhere deep inside the model, a function suddenly creates a new Task, discards its handle and returns.

Perhaps the app compiles. Perhaps the happy path works. But has anyone redesigned the operation—or have we simply sewn a new concurrency mechanism onto an old structure?

This is what we mean by a Frankenstein architecture: leftovers from different designs, stacked on top of one another and stitched together without a coherent explanation of how the whole app behaves. The danger is not its appearance. It is that nobody can confidently explain who owns the work, when it finishes, or which result is allowed to change the app.

🎯 Your goal: choose one feature, trace its asynchronous work, and identify the decisions that the architecture leaves unanswered.

A legacy app is not automatically a monster. GCD and Swift Concurrency can coexist during a careful migration. Keep working code where it has a clear contract. Investigate the seams where those contracts disappear.

02 · ARCHITECTURE REVIEW

1. Task has become the new dispatch button

LaunchViewModel.swift · refresh callback excerpt

func refresh() {
    isLoading = true
    status = "Loading"
    manager.refresh { [self] in
        isLoading = false
        status = "Refresh complete"
        // FA-05: another writable snapshot with no rule for following later updates.
        displayedLaunch = manager.launch
    }
}

What the caller believes: the refresh is complete. In this lab, the manager invokes this callback immediately, so the screen copies the previous model value.

Look for: new Task blocks inserted wherever the old implementation wanted something to happen elsewhere. The surrounding function still looks synchronous and its callers still assume the same ordering.

In the downloadable project, LegacyLaunchManager.refresh(completion:) starts work and immediately invokes its callback. The caller above clears the loading state before any response arrives.

Ask: does this method mean “start a refresh”, “finish a refresh”, or “join the refresh already in progress”? The name, return value and caller must agree.

A clearer direction: if this is one step in an existing asynchronous operation, expose an async operation and await it. If it intentionally starts independently managed work, make that lifetime and ownership explicit.

Creating a Task is not a general instruction to move work to a different thread. In particular, a Task created in a main-actor context can inherit that isolation. Inspect isolation and the actual workload before making performance claims.

03 · ARCHITECTURE REVIEW

2. Work appears deep in the model with no accountable owner

LaunchViewModel.swift · stop() excerpt

func stop() {
    // FA-02: only presentation changes. The underlying operation keeps going.
    isLoading = false
    status = "Stopped"
    manager.journal.record("Stop changed the label; no operation was cancelled")
}

Try it: start a refresh, press Stop, then deliver its response. This method changes presentation state without cancelling the Task hidden inside the manager.

Look for: a repository, helper or model method that creates an unstructured Task without retaining or returning its handle, while changing state that matters to the feature.

Ask: who needs to observe completion? Who handles failure? Who can request cancellation? What happens on logout, another tap or replacement of the feature? Can this operation legitimately outlive its caller?

A discarded handle does not stop the Task. Nor does it prove the original developer lacked understanding. It removes an obvious way to await or cancel that work, so the design needs another convincing explanation.

The finding is the missing contract. “Task without a handle” is a search result. “A cancelled search can still publish results because nobody owns its lifetime or validates its response” is an architectural finding.

A clearer direction: give meaningful independent work an explicit owner. In CFA, the screen ViewModel owns its button-driven workflow; a Feature can own shared operations that must survive a screen. A system event can also legitimately start work. Ownership follows the required lifetime, not how close the code is to a button.

04 · ARCHITECTURE REVIEW

3. Nested Tasks are mistaken for child tasks

Beyond this lab: this is an additional review check, not a fault claimed for the downloadable project.

Look for: a Task created inside another Task, accompanied by an assumption that the outer operation will wait for it or cancel it automatically.

Nesting Task closures in the source does not establish structured parent–child lifetime management. A Task created with Task { } is unstructured. The enclosing braces can make the code look more coordinated than it is.

Ask: is this work a sequential step, independent child work within the same operation, or an intentionally longer-lived operation?

  • Sequential step: call the async function and await its result. Another task is often unnecessary.
  • Independent work belonging to this operation: consider async let or a task group, with a clear result and failure policy.
  • Work with a separate lifetime: give it an explicit owner and define how callers observe it.

Structured children cannot simply escape their scope unfinished. That property is useful when the whole operation should finish together. It is the wrong lifetime if the product requires work to continue after the screen has gone.

Swift’s structured concurrency proposal explains these lifetime distinctions.

05 · ARCHITECTURE REVIEW

4. Every tap creates another operation

FrankensteinBehaviourTests.swift · overlapping refresh excerpt

let screen = setup()
screen.refresh(); screen.refresh()
await screen.manager.transport.waitForRequests(2)
#expect(screen.manager.transport.requestCount == 2)

Two taps, two requests. This characterization test intentionally confirms the fault. The lab has no single-flight or replacement policy.

Look for: every Refresh, Save or Retry action starting new work, without deciding whether overlapping requests are allowed. Disabling one button is the only protection, even though other screens or system events can call the same feature.

Ask: should a repeated request join existing work, replace it, queue behind it, or be rejected? Are the inputs actually the same?

A clearer direction: put the shared policy in the owner that sees all callers. Presentation can prevent accidental taps, but it cannot enforce a feature-wide rule by itself. In CFA, a Feature can retain one in-flight operation and let callers await it when sharing is the chosen policy.

Prove it: trigger the same request from two callers before the first finishes. Then repeat with changed inputs. Verify the intended number of operations and the outcome each caller receives.

06 · ARCHITECTURE REVIEW

5. Yesterday’s result can overwrite today’s intention

FrankensteinBehaviourTests.swift · out-of-order response excerpt

screen.manager.transport.deliver(2)
await screen.manager.journal.waitForPublications(1)
#expect(screen.manager.launch?.revision == 2)
screen.manager.transport.deliver(1)
await screen.manager.journal.waitForPublications(2)
#expect(screen.manager.launch?.revision == 1)

Read the revisions: response 2 publishes, then response 1 replaces it. These assertions pass when the stale-result bug is reproduced.

Look for: search A starts, search B starts, B finishes first, then A replaces B on screen. Or a refresh begun under one account publishes after the user switches accounts.

Ask: what makes a result eligible to publish now? Does the operation still belong to the current query, account or editing session?

A clearer direction: check the relevant identity or state before applying a result. Cancellation is cooperative; requesting it is not proof that the old operation has stopped. Retaining a handle is useful, but it does not implement a relevance policy.

Prove it: use a controlled test dependency to complete B before A. Confirm that the older result cannot replace the current one. Include failure and cancellation, not just two successful responses.

07 · ARCHITECTURE REVIEW

6. Actors are treated as locks around entire workflows

LegacyLaunchManager.swift · isolated Task excerpt

Task { @MainActor in
    do {
        let result = try await self.transport.downloadLaunch()
        // FA-03: any response wins, including an older request arriving last.
        self.launch = result

The same lab fault, viewed through isolation: the Task explicitly uses the main actor, yet an older response can still publish last. This example demonstrates result ordering; it does not demonstrate a check-before-await reservation bug.

Look for: a method checks shared state, awaits external work, then acts as if its earlier observation must still be true. The explanation is simply “it is an actor”.

Actor isolation protects access to isolated state. It does not make a multi-step asynchronous workflow indivisible. Other work can change that state while the method is suspended.

Ask: which assumptions must still hold when this operation resumes? Could the reservation, selected account, available balance or request identity have changed?

A clearer direction: make the state transition explicit. Revalidate after suspension where necessary, and define how competing operations are accepted or rejected. Do not sprinkle extra actor annotations onto a logical ordering problem.

Prove it: pause at the external dependency, perform the competing action, then resume the first operation and inspect the outcome.

Swift’s actors proposal discusses reentrancy and the assumptions that cross suspension points.

08 · ARCHITECTURE REVIEW

7. Scheduling mechanisms accumulate instead of forming a boundary

LegacyLaunchManager.swift · opening of the scheduling chain

DispatchQueue.main.async {
    // FA-02: no retained/returned handle, no shared-request or cancellation policy.
    Task { @MainActor in
        do {
            let result = try await self.transport.downloadLaunch()

Trace the boundary: a main-queue closure creates a main-actor Task. The excerpt stops at the download call. There is no semaphore in this lab; blocking bridges below are a wider review concern.

Look for: a queue dispatches into a Task, which invokes a callback, which dispatches again, with a semaphore added to make the result synchronous. Nobody can explain which hop is required.

Ask: is each transition adapting a real external API, or compensating for an unclear internal contract? Does a wait block a thread needed to make progress?

A clearer direction: isolate a necessary legacy bridge at a named boundary. Let the feature consume a clear asynchronous interface. Preserve callback ordering, cancellation and error behaviour during migration. If using a checked continuation, account for every path that must resume it exactly once.

A callback-based SDK is not a reason to rewrite the whole app. An undocumented chain of scheduling repairs is a reason to stop and trace it.

09 · ARCHITECTURE REVIEW

8. Several objects claim to own the same truth

LaunchViewModel.swift · snapshot excerpt

manager.refresh { [self] in
    isLoading = false
    status = "Refresh complete"
    // FA-05: another writable snapshot with no rule for following later updates.
    displayedLaunch = manager.launch
}

A second writable value: displayedLaunch receives a snapshot before the response arrives. Later changes to manager.launch do not automatically refresh this copy.

Look for: the screen, its ViewModel, a singleton and a repository each maintain a writable copy of the same collection. Notifications and manual refresh calls try to keep them aligned.

Ask: which owner decides what the current collection contains? Which values are deliberate snapshots or editing drafts, and which are competing authorities?

A clearer direction: identify one authoritative owner for each mutable responsibility. Keep a local draft when editing requires it, but define when it is committed, discarded or refreshed. A copy is not automatically a defect; an unexplained synchronisation policy is.

Prove it: change the data from another screen. Check what the original screen should display, including any unsaved draft.

10 · ARCHITECTURE REVIEW

9. The UI has become the business-rule engine

Beyond this lab: this is an additional review check, not a fault claimed for the downloadable project.

Look for: Views deciding retry policies, choosing databases, building API providers or coordinating several shared features. Meanwhile, the model mostly stores values.

Ask: would the same rule be needed from another screen or a system event? Can it be exercised without constructing the interface?

A clearer direction: keep shared behaviour with its Feature. Keep presentation state and screen workflows with the ViewModel. Keep provider-specific communication and persistence behind their respective boundaries. In CFA, AppModel composes the shared features.

Moving an unexplained Task from a repository into a View does not repair the architecture. The repair is assigning the operation to the owner that can uphold its contract.

11 · ARCHITECTURE REVIEW

10. New names hide the same responsibilities

Beyond this lab: this is an additional review check, not a fault claimed for the downloadable project.

Look for: Manager, Service, Worker and Coordinator types with nearly identical properties and forwarding methods. Or one “background actor” handling unrelated APIs, file storage, image processing and business rules.

Ask: finish the sentence “This type owns…” with a concrete responsibility. Can two types truthfully give the same answer? Can one type give five unrelated answers?

A clearer direction: remove layers that add no meaningful boundary, and separate responsibilities that genuinely need distinct owners. CFA has deliberate screen, Feature and provider boundaries; their value comes from the behaviour they organise, not their filenames.

A neat folder diagram can conceal a stitched-together implementation. Follow the actual call path before trusting the labels.

12 · ARCHITECTURE REVIEW

11. Errors disappear and startup performs hidden work

LegacyLaunchManager.swift · failure path excerpt

} catch {
    // FA-04: failure is silently converted into an empty collection state.
    self.launch = nil
    self.journal.record("Request failed; model erased the launch instead of exposing the error")
}

Failure becomes absence. The journal records a diagnostic, but the model exposes no error state. The useful launch is erased. Hidden startup effects are a separate checklist concern, not a fault this lab demonstrates.

Look for: initialisers that start downloads, getters that mutate storage, empty catch blocks, or failed loads turned into empty arrays that the UI presents as success.

Ask: what starts this effect? Who can retry it? Can a caller distinguish “no records exist” from “records could not be loaded”? Is unrelated startup work preventing useful UI from appearing?

A clearer direction: name commands for their effects, keep construction predictable, and expose meaningful outcomes. Preserve useful cached data when the product calls for it, but represent freshness and failure honestly. A fallback that changes the truth is another patch stitched over a missing decision.

Prove it: fail the dependency deliberately, recover it, and retry. Confirm that the interface remains usable and the state tells the truth throughout.

13 · ARCHITECTURE REVIEW

12. The evidence stops at “it compiles”

FrankensteinBehaviourTests.swift · stopped-work excerpt

@Test func stoppedWorkStillPublishes() async {
    let screen = setup()
    screen.refresh()
    await screen.manager.transport.waitForRequests(1)
    screen.stop()
    #expect(screen.status == "Stopped")
    screen.manager.transport.deliver(1)
    await screen.manager.journal.waitForPublications(1)
    #expect(screen.manager.launch?.revision == 1)
    #expect(screen.status == "Stopped")
}

A passing test can document a broken contract. This test proves that a response still publishes after Stop. A repaired implementation needs a test asserting its intended cancellation and publication policy.

Look for: successful builds offered as proof of cancellation, latest-result behaviour or correct feature ownership. Tests cover one fast request while the production problem requires two overlapping requests.

Ask: which observable rule is this test establishing? Could it pass while the suspected bug still exists?

A clearer direction: test the orderings that threaten the rule. Use controllable dependencies for slow responses, failures, cancellation and retries. Avoid making the outcome depend on arbitrary sleeps. Compiler checking is valuable; it does not decide which search the user wants or when a purchase-related operation should be retried.

Architecture becomes credible when you can explain its behaviour and demonstrate it under the conditions that previously broke it.

14 · ARCHITECTURE REVIEW

Turn the checklist into an AI-assisted investigation

Choose one feature rather than asking AI to “fix the entire architecture”. Trace a single intent from its entry point to the final state change. For each finding, record the file and method, the missing decision, a plausible failure sequence and the evidence needed to resolve it.

Use this review prompt:

Review this feature without changing code. Trace its entry points, mutable state owners and asynchronous operations. For every Task, identify its creator, lifetime, handle ownership, completion, cancellation and error policy. Distinguish an awaited sequential call from structured child work and deliberately independent work. Check repeated requests, stale responses and assumptions across await. Cite the actual files and methods. Separate confirmed faults, unresolved questions and intentional tradeoffs. Do not infer the developer’s competence from a single construct.

Then ask for a repair proposal:

For the highest-impact finding, propose the smallest change that preserves product behaviour. Should this remain in the current task, become structured child work, or belong to a longer-lived owner? If it begins from a user action, explain the ViewModel’s role; if it is shared or system-driven, explain the Feature’s role. Show who observes completion and handles failure. State the repeated-request policy and the test that would demonstrate it. Do not add new layers unless they own a concrete responsibility.

Classify each result as confirmed fault when there is evidence of broken behaviour, unresolved contract when the intended behaviour cannot be established, or intentional tradeoff when the design is explicit and supported. Ten search hits are not ten defects.

👉 Review one refresh or save operation now. Write its ownership sentence, then test one difficult sequence before changing another feature.

15 · ARCHITECTURE REVIEW

Replace the stitches with decisions

A Frankenstein architecture is rarely created in one dramatic moment. It grows through reasonable-looking local fixes: one more Task, one more dispatch, one more copy of the state, one more abstraction to make today’s problem disappear.

The way out is equally concrete. Decide who owns the work. Decide when it finishes. Decide what repeated actions mean. Decide which results may publish. Then make those decisions visible in the code.

Our CFA AI Toolkit for iOS provides the fuller guidance for reviewing, organising and modernising features around these responsibilities. CFA is our architectural approach, not a structure Swift requires every app to adopt. Use the toolkit to give AI a coherent set of rules and to hold its proposed changes to those rules.

The three training days, individual lessons, topic-focused Study Packs and supporting guides help you build the mental model needed to review those changes yourself.

Start with 3 Days of Swift Concurrency →

The objective is an app whose behaviour you can explain—not merely a legacy app wearing modern syntax.

16 · ARCHITECTURE REVIEW

Download the Frankenstein architecture lab

Next Launch: Frankenstein Architecture makes the warning signs observable. Open the Xcode project, trigger overlapping refreshes and choose which response arrives first. Watch an old result overwrite a newer one, a Stop action leave work running, and a failed request erase useful data.

This is an intentionally flawed teaching example inspired by Next Launch. Its faults are documented so you can trace each symptom to an ownership or lifetime decision. It uses simulated responses: no API keys or live service are required.

Download the Xcode project · ZIP · 1.7 MB ↓

  • SwiftUI iOS project with five documented architecture findings.
  • A branded review guide, source annotations and four characterization tests.
  • Manual response controls for repeatable investigations without timing guesses.

Start here: unzip the download and open REVIEW-GUIDE.html. Then open NextLaunchFrankenstein.xcodeproj in Xcode. Follow the guide’s experiments before trying to repair the code.

Verification: the Swift sources passed iOS-target type checking with Swift 6 strict concurrency, and all four core characterization tests passed on macOS. These tests deliberately reproduce the documented faults. A full iOS build and Simulator run have not been verified: asset compilation was blocked by the validation environment’s Simulator access. See VALIDATION.txt in the archive for details.

Use the checklist above to explain each failure, propose a coherent owner and lifetime, and write a test for the corrected behaviour.

bottom of page