DEV Community

Todd Sullivan
Todd Sullivan

Posted on

AI Features Need Product Edges, Not Just Better Prompts

Most AI features don't fail because the model is bad.

They fail because everything around the model is treated like a demo.

This week I was tightening an iOS workout app that uses Claude through Supabase Edge Functions. The model part is straightforward: send training context, get a structured exercise or plan back, validate it, write it into SwiftData.

The less glamorous work was the part that makes it feel like an actual product:

  • monthly AI credit balance
  • offline handling
  • auth token storage
  • disabled states while generation is running
  • different rules for “add exercise” vs “swap this exercise”
  • tests for the boring edge cases

That is where most AI app quality lives.

One button, several states

The UI has a small “fill with AI” affordance on the exercise editor. Underneath it, the button is not just “call endpoint”. It has to know whether suggestion is currently allowed:

var canSuggest: Bool {
    guard !isLoadingAI,
          NetworkMonitor.shared.isOnline,
          (creditsRemaining ?? 1) > 0
    else { return false }

    // Swap mode can use the original exercise as context.
    // Add mode needs the typed name as a hint.
    return isSwapMode || !name.trimmingCharacters(in: .whitespaces).isEmpty
}
Enter fullscreen mode Exit fullscreen mode

That little predicate is doing a lot of product work.

If the user is offline, don't let them tap into a doomed network request.
If a generation is already running, don't double-spend.
If they have zero credits, don't pretend the feature is available.
If they are swapping an existing exercise, don't force them to type a name because the old exercise is already useful context.

The model does not care about any of this. The user does.

Credits should be part of the response

The suggestion response includes the updated credit count:

struct SuggestedExercise: Decodable {
    let name: String
    let briefDescription: String
    let muscleGroup: String
    let sets: Int
    let repTargetLow: Int
    let repTargetHigh: Int
    let restSeconds: Int
    let isDualDumbbell: Bool
    let creditsRemaining: Int
}
Enter fullscreen mode Exit fullscreen mode

Then the view model updates local state immediately after a successful fill:

creditsRemaining = result.creditsRemaining
aiFilledFields = true
Enter fullscreen mode Exit fullscreen mode

That avoids the classic AI-product weirdness where the backend knows the user has spent a credit but the UI keeps showing stale allowance until the next refresh.

It is also easier to test. In the app tests, the credit transition is explicit:

func testAIDisabledWhenCreditsReachZero() {
    let vm = ExerciseEditViewModel(mode: .add(makeDay()), context: container.mainContext)
    vm.name = "Row"
    vm.creditsRemaining = 1
    XCTAssertTrue(vm.canSuggest)

    vm.creditsRemaining = 0
    XCTAssertFalse(vm.canSuggest)
}
Enter fullscreen mode Exit fullscreen mode

There are 13 tests just around the exercise edit view model, plus separate coverage for offline error mapping. Not because this is academically interesting, but because this is the stuff that breaks in front of real users.

Offline is not a server error

Another small detail: connectivity failures are mapped separately from backend failures.

static let connectivityCodes: Set<URLError.Code> = [
    .notConnectedToInternet,
    .networkConnectionLost,
    .timedOut,
    .cannotConnectToHost,
    .dataNotAllowed,
]
Enter fullscreen mode Exit fullscreen mode

The resulting message is intentionally plain:

"You're offline. Reconnect to use AI features."
Enter fullscreen mode Exit fullscreen mode

No “unexpected server response”. No fake intelligence. Just tell the user what happened.

The actual lesson

Shipping AI features is mostly normal software engineering with a probabilistic dependency in the middle.

The model call matters, but the surrounding contract matters more:

  • Can the user invoke it right now?
  • What happens if the network dies?
  • Is usage counted consistently?
  • Does the UI reflect server state immediately?
  • Are the edge cases testable without calling the model?

Once those pieces are in place, the AI feature stops feeling like a prompt wired to a button and starts feeling like part of the app.

That is the bar I keep coming back to: not “does the model answer?” but “does this survive normal product reality?”

Top comments (0)