What happens when you stop writing the code and let an AI coding agent build a real mobile feature? I put it to the test, with most of the experiment focused on Swift and iOS.
I've been using AI-assisted development for quite some time now.
Like many developers, I've gone from using AI to explain code, to generating boilerplate, to debugging errors, to asking it to refactor code, write tests, and increasingly, to letting coding agents work across multiple files.
And that got me thinking about a slightly uncomfortable question:
What happens if I stop telling the AI how to implement a feature and simply ask it to build one?
Not a toy application.
Not a "Hello World" example.
Not a screen with a button and a network call.
A reasonably realistic mobile feature with asynchronous work, caching, cancellation, UI state, error handling, and tests.
So I decided to try an experiment.
I would give an AI coding agent a specification.
I would let it make the implementation decisions.
And then I would review the result like I would review a pull request from another engineer.
The interesting part wasn't whether the AI could produce code.
I already knew it could.
The interesting part was:
Could it produce code that I would actually be comfortable maintaining?
And that's where things got interesting.
The experiment
I'm framing this as a mobile development experiment, but most of the implementation and debugging examples are deliberately focused on iOS and Swift.
The reason is simple: that's where I spend most of my engineering time, and Swift's concurrency and SwiftUI model provide some particularly interesting places for AI-generated code to go wrong.
The experiment has a simple structure:
The goal isn't to prove that AI is bad at programming.
Quite the opposite.
The goal is to understand where AI is genuinely useful and where engineering judgment still matters.
The rules
To make the experiment meaningful, I wanted to remove as much hand-holding as possible.
The AI gets:
- a feature specification
- the existing project structure
- access to the relevant codebase
- the ability to create and modify files
- the ability to run tests
- the ability to build the application
But I deliberately don't give it the implementation.
In other words, I don't say:
"Use an actor here."
I don't say:
"Use MVVM."
I don't say:
"Use an async image loader."
I don't say:
"Use a dictionary to deduplicate requests."
Instead, I give it the problem.
That distinction matters.
Because otherwise I'm not really testing the AI's engineering ability.
I'm testing its ability to follow my instructions.
The feature
I wanted something small enough to understand but complex enough to expose engineering problems.
So let's build a product search feature.
The requirements are:
Functional requirements
The application should:
- Allow the user to enter a search query.
- Fetch products from a remote API.
- Display results in a scrolling list.
- Load product images asynchronously.
- Cache previously loaded images.
- Avoid duplicate image downloads.
- Support pagination.
- Cancel unnecessary requests.
- Show loading, empty, and error states.
- Retry failed requests.
- Work correctly when the user rapidly changes the search query.
Non-functional requirements
The implementation should:
- use Swift concurrency
- be testable
- avoid unnecessary network calls
- avoid obvious memory leaks
- handle cancellation correctly
- maintain a responsive UI
- separate UI concerns from networking
- avoid putting business logic directly into the SwiftUI view
That's enough.
I don't tell the AI how to achieve any of it.
The first prompt
The actual prompt can be surprisingly simple.
Build a production-quality product search feature for this iOS application.
Requirements:
- Search products using a remote API.
- Display results in a SwiftUI list.
- Support pagination.
- Load product images asynchronously.
- Cache images.
- Deduplicate simultaneous requests for the same image URL.
- Cancel obsolete network requests.
- Handle loading, empty, error and retry states.
- Rapidly changing search queries must not result in stale results being displayed.
- The implementation must be testable.
- Follow the existing project's architecture and conventions.
- Use Swift concurrency.
- Do not introduce unnecessary dependencies.
First inspect the existing project structure and identify the appropriate place for this feature.
Implement the feature completely.
Run the relevant tests and fix compilation/test failures.
And then I stepped back.
First surprise: AI is really good at the first 80%
The first thing that's easy to underestimate is how much code an AI coding agent can produce very quickly.
Within a relatively short amount of time, you can get:
SearchView
SearchViewModel
ProductRepository
APIClient
ImageLoader
ImageCache
Models
Tests
It can wire the pieces together.
It can generate models.
It can create async networking code.
It can write SwiftUI.
It can generate tests.
It can run the compiler.
It can fix obvious compiler errors.
And this is where the AI experience feels almost magical.
You go from:
"Here's the requirement."
to:
"Here's a working implementation."
much faster than traditional development.
But "working" and "production-ready" are very different things.
The first code review
This is where I stopped looking at the AI as a code generator.
I started looking at it as an engineer submitting a pull request.
And I asked the same questions I would ask during any serious code review:
- What are the invariants?
- Where is state owned?
- What happens when requests overlap?
- What happens when work is cancelled?
- What happens when an async operation suspends?
- Can stale data overwrite fresh data?
- Can two callers trigger the same network request?
- What happens when the view disappears?
- What happens under memory pressure?
- Are the tests testing behavior or implementation?
- What happens when the network fails halfway through pagination?
And suddenly, the amount of code wasn't the interesting part anymore.
The interesting part was the reasoning behind the code.
Problem #1: "But it's an actor, so we're thread-safe... right?"
This is one of the most interesting places where Swift concurrency can fool you.
Suppose the AI creates an image loader:
actor ImageLoader {
private var tasks: [URL: Task<Data, Error>] = [:]
func load(_ url: URL) async throws -> Data {
if let existingTask = tasks[url] {
return try await existingTask.value
}
let task = Task {
try await download(url)
}
tasks[url] = task
return try await task.value
}
}
At first glance, this looks pretty good.
It's an actor.
The dictionary is protected.
Concurrent access is isolated.
Requests for the same URL are deduplicated.
Done?
Not quite.
The important thing about actors is not simply:
"Only one thing can access this data."
The more important concept is:
An actor can suspend, and when it resumes, the state may no longer be what it was before the suspension.
That's actor reentrancy.
Imagine:
func load(_ url: URL) async throws -> Data {
if let existingTask = tasks[url] {
return try await existingTask.value
}
let task = Task {
try await download(url)
}
tasks[url] = task
return try await task.value
}
There is an await.
The actor can process other messages while this operation is suspended.
That means the mental model:
enter actor
↓
nothing else can happen
↓
leave actor
is incorrect.
The better mental model is:
enter actor
↓
read state
↓
await
↓
release actor
↓
other work can execute
↓
resume
↓
state may have changed
This distinction is subtle.
And it is exactly the sort of thing that can slip through an AI-generated implementation because the code looks perfectly reasonable.
Why this matters outside image loading
This isn't just an image-loader problem.
The same reasoning applies to:
- token refresh
- request deduplication
- authentication state
- database operations
- pagination
- task registries
- cache updates
- connection managers
- download managers
Whenever you have:
actor Something {
var state: State
func operation() async {
// read state
await something()
// assume state is unchanged
}
}
you should ask:
What could have changed while I was suspended?
That question is often more valuable than asking:
"Is this code using actors?"
Problem #2: Cancellation that doesn't actually cancel the work
Another common AI-generated pattern looks like this:
func search(query: String) async {
isLoading = true
do {
let products = try await repository.search(query)
self.products = products
} catch {
self.error = error
}
isLoading = false
}
Then somewhere in the UI:
searchTask?.cancel()
searchTask = Task {
await viewModel.search(query: query)
}
Looks reasonable.
The old task gets cancelled.
The new task starts.
But cancellation in Swift is cooperative.
Calling:
task.cancel()
doesn't magically terminate every operation inside that task.
The work has to observe cancellation.
For example:
try Task.checkCancellation()
or:
if Task.isCancelled {
return
}
And importantly, the underlying operation also needs to behave correctly when cancellation occurs.
This becomes particularly important with:
User types:
"i"
↓
request A
"ip"
↓
request B
"iph"
↓
request C
"iphone"
↓
request D
If requests A, B and C don't actually stop, you can have multiple operations running unnecessarily.
Even worse:
Request A ────────────────┐
↓
Request D ──────────→ UI
↑
Request A ────────────────┘
Now an old request can potentially interfere with newer state if the architecture isn't careful.
Problem #3: The stale response problem
This is one of those bugs that looks fine during normal testing.
Suppose the user searches:
shoes
Then immediately:
jackets
The requests execute concurrently.
But network timing isn't deterministic.
You could get:
Search "shoes"
│
├───────────────→ Server
│
Search "jackets"
│
├──────→ Server
│
│
│ jackets response
│ ↓
│ UI
│
│ shoes response
│ ↓
│ UI ❌
The UI now shows shoes even though the current query is jackets.
The AI can write perfectly valid asynchronous code and still miss the semantic race.
One solution is to associate requests with a generation/token:
let requestID = UUID()
currentRequestID = requestID
let results = try await repository.search(query)
guard requestID == currentRequestID else {
return
}
products = results
Another is to structure the task lifecycle so obsolete tasks are cancelled and stale results cannot be committed.
The exact implementation depends on the architecture.
The important lesson is:
Concurrency correctness is not the same thing as compiler correctness.
The compiler can verify that your code respects actor isolation.
It cannot tell you that your application displayed the results for the wrong search query.
Problem #4: The cache looked correct
The AI also generated a cache.
Something along the lines of:
final class ImageCache {
private var cache: [URL: Data] = [:]
func image(for url: URL) -> Data? {
cache[url]
}
func insert(_ data: Data, for url: URL) {
cache[url] = data
}
}
Again:
Perfectly understandable.
But a production cache isn't just:
Dictionary<URL, Data>
You immediately have questions:
How large can it become?
What happens if the application displays thousands of images?
What happens under memory pressure?
Should everything remain in memory?
Is this memory cache or disk cache?
What is the eviction policy?
What happens if two requests arrive simultaneously?
Do we cache failed requests?
What about HTTP cache headers?
What happens when a low-resolution image is replaced by a high-resolution image?
What happens if the same URL is requested by 20 cells at the same time?
That last question brings us back to request deduplication.
The real system looks more like:
Image Request
│
▼
Memory Cache
│ │
hit│ │miss
│ ▼
│ In-flight
│ Request
│ │
│ ▼
│ Disk/HTTP
│ │
│ ▼
│ Network
│
▼
Image
The code isn't necessarily difficult.
The state machine is.
Problem #5: The tests looked impressive
This was perhaps one of the more interesting observations.
AI is extremely good at generating tests that look like tests.
For example:
func testSearchReturnsProducts() async throws {
let products = try await sut.search(query: "shoes")
XCTAssertEqual(products.count, 3)
}
Great.
But what behavior does this actually protect?
Not much.
The important tests are often the uncomfortable ones:
✓ successful request
✓ empty response
✓ network failure
✓ retry
✓ cancellation
✓ rapid query changes
✓ stale response
✓ duplicate image requests
✓ concurrent image requests
✓ pagination failure
✓ pagination cancellation
✓ view disappears during request
✓ request completes after cancellation
✓ cache hit
✓ cache miss
✓ cache eviction
And suddenly the test suite becomes a lot more interesting.
The difference between code coverage and confidence
This is an important distinction.
You can have:
95% code coverage
and still have a broken application.
Coverage answers:
"Did this code execute?"
It doesn't necessarily answer:
"Did this system behave correctly under adverse conditions?"
For concurrent systems, that's a huge distinction.
You want tests around state transitions, not just lines of code.
For example:
idle
↓
loading
↓
success
is one path.
But:
idle
↓
loading
↓
cancelled
is another.
And:
loading
↓
new search
↓
old request completes
↓
stale response discarded
is another.
The second category is where a lot of real bugs live.
What the AI did surprisingly well
This experiment isn't about proving that AI is bad.
There were plenty of things it did extremely well.
AI is particularly powerful at:
1. Boilerplate
Models, DTOs, initial views, protocols, test scaffolding.
2. Repetitive transformations
Renaming, moving code, extracting types, updating call sites.
3. First-pass implementations
Getting from zero to something compilable is dramatically faster.
4. API exploration
It can quickly suggest different approaches and explain unfamiliar APIs.
5. Test scaffolding
It can generate a large amount of initial test structure.
6. Debugging compiler errors
This is one of the areas where AI feels almost unfairly good.
Give it:
error: Main actor-isolated property cannot be mutated
and it can often get you surprisingly close to the solution.
7. Documentation
Turning implementation details into readable documentation is another excellent use.
Where I still wanted a human engineer
The interesting gap wasn't syntax.
It was judgment.
Consider these questions:
Should this state be owned by the ViewModel?
Should this operation be cancellable?
Is this cache global or feature-scoped?
Is this actor actually solving the problem?
What happens if the user changes the query 10 times in one second?
Should this request be retried?
What happens when the app goes into the background?
What happens under memory pressure?
Is this architecture appropriate for a 5-year-old production application?
These aren't simply code-generation questions.
They are system-design questions.
AI is very good at local reasoning
This is perhaps the biggest lesson from the experiment.
AI can look at:
func loadImage(url: URL)
and produce a reasonable implementation.
But the real question is:
What does this function mean to the rest of the system?
That requires broader context.
For example:
ImageLoader
│
├── ProductCell
│
├── SearchResults
│
├── ProductDetails
│
├── Recommendations
│
└── Wishlist
Now the question isn't:
"Can I load an image?"
It becomes:
"Who owns image loading?"
"Who owns caching?"
"Can these features share requests?"
"What is the lifetime of the cache?"
"What happens when the user logs out?"
"What happens when the CDN changes the image?"
That is a very different level of reasoning.
The AI doesn't need to be perfect to be incredibly useful
And this is where I think the conversation around AI coding often becomes unnecessarily binary.
The choices aren't:
AI replaces developers
or:
AI is useless
There is a much more interesting middle ground.
I increasingly think about AI coding agents like this:
Human
│
┌───────────┴───────────┐
│ │
Judgment Direction
│ │
▼ ▼
AI Agent ───────────→ Implementation
│
▼
Validation
│
▼
Human
The human doesn't necessarily need to write every line.
But the human needs to know:
Which lines should exist in the first place.
The role of the senior engineer changes
This is probably the bigger question behind the whole experiment.
If AI becomes capable of producing:
models
views
networking
tests
boilerplate
refactors
documentation
then the value of an engineer increasingly shifts toward:
Problem definition
↓
System design
↓
Constraints
↓
Trade-offs
↓
Validation
↓
Debugging
↓
Observability
↓
Product judgment
In other words:
The scarce skill may no longer be producing code. It may be knowing whether the code should exist.
AI-generated code needs a stronger definition of "done"
Traditionally, "done" might mean:
✓ Compiles
✓ Tests pass
✓ Feature works
For AI-assisted development, I think we need a more demanding definition:
✓ Compiles
✓ Tests pass
✓ Correct under concurrency
✓ Handles cancellation
✓ Handles failure
✓ Doesn't leak resources
✓ Doesn't introduce unnecessary complexity
✓ Meets architectural boundaries
✓ Has acceptable performance
✓ Is observable
✓ Is maintainable
✓ Doesn't violate security/privacy requirements
And perhaps most importantly:
✓ I understand why this code exists
That last check becomes incredibly important when the code wasn't written manually.
The uncomfortable question
Here's the part I've been thinking about the most.
If an AI agent can generate 80% of a feature...
What happens to the engineer?
I don't think the answer is:
"The engineer disappears."
I think the answer is:
The engineer moves up the abstraction stack.
Instead of spending most of the time typing:
struct ProductViewModel {
...
}
we may spend more time deciding:
What should ProductViewModel own?
What should it not own?
Where does intelligence belong?
What are the concurrency invariants?
What are the failure modes?
How do we know this system is correct?
And ironically, that could make deep engineering knowledge more valuable, not less.
Because if AI can write the obvious code, the differentiator becomes understanding the non-obvious code.
What I would do differently next time
After going through this exercise, I wouldn't simply ask an AI:
"Build this feature."
I'd give it a stronger engineering contract.
Something like:
ENGINEERING CONSTRAINTS
1. Use structured concurrency.
2. Cancellation must propagate correctly.
3. Do not use unstructured concurrency unless justified.
4. UI mutations must respect actor isolation.
5. Do not assume actor isolation eliminates logical races.
6. Network requests must be testable through dependency injection.
7. Duplicate requests for the same resource must be deduplicated.
8. Stale responses must never overwrite newer state.
9. Tests must cover failure and cancellation paths.
10. Do not introduce dependencies without justification.
11. Do not invent APIs.
12. Preserve existing architectural boundaries.
13. Explain architectural decisions before making large changes.
Notice what happened.
I didn't give the AI the implementation.
I gave it the engineering principles.
That's a much more interesting way of working with AI.
The future isn't "AI writes code"
I think that framing is already becoming too simplistic.
The more interesting future looks like:
Human
│
Requirements
│
▼
Specification
│
▼
AI Agent
│
┌────────┼────────┐
▼ ▼ ▼
Design Code Tests
│ │ │
└────────┼────────┘
▼
Validation
│
▼
Human review
│
▼
Production
The engineer becomes the person who establishes the boundaries within which the agent operates.
And that's a very different skill from simply knowing how to type Swift.
Final thoughts
So, can AI build a mobile application feature?
Absolutely.
Can it produce surprisingly good code?
Yes.
Can it save a developer a huge amount of time?
Without a doubt.
But can I look at a generated feature, see that it compiles, see that the tests pass, and immediately assume it is production-ready?
No.
And that's not necessarily a criticism of AI.
It's a reminder that software engineering has never really been about writing code.
Code is the output.
The difficult part has always been understanding the system.
Understanding state.
Understanding failure.
Understanding concurrency.
Understanding trade-offs.
Understanding what happens when the happy path disappears.
And perhaps most importantly:
Knowing what questions to ask before the bug happens.
That's the part I'm not ready to outsource.
Not yet.
One last thought
Maybe the future senior engineer isn't the person who can write the most code.
Maybe it's the person who can look at 10,000 lines generated by an AI agent and know which 10 lines are going to hurt you six months from now.
And if that's true, then learning how to work with AI isn't about becoming better at prompting.
It's about becoming a better engineer.







Top comments (0)