29 · The means of payment that never loads
A "clean architecture" use case that fetches a member's card — layered on paper, but it hangs on two paths, imports UIKit into the domain and builds its own dependencies. Review it.
Shape: review this PR · Reported: a European fintech's Payments team, 2026 — 10–15 minutes of live review after a behavioural round, judged on SOLID, clean architecture, testability and error handling · Verified: the snippet compiles clean in Swift 5 and Swift 6 mode for iOS; the original was run through its dead ends against stub services; the fix and its four tests run with Swift 6.4
"This is the domain layer behind our means-of-payment screen. The presenter and view controller exist, but you don't need them. Walk me through what you'd change — and why it matters."
// MARK: - PRESENTATION LAYER (Using MVP)
// PaymentMethodViewController
// MeansOfPaymentPresenter
// MARK: - DOMAIN LAYER
import UIKit
class MeansOfPaymentEntity {
var membership: Membership?
var card: Card?
var cardIcon: UIImage! {
switch card?.cardLevel {
case .standard:
return UIImage(named: "ic_card_standard")
default:
return UIImage(named: "ic_card_plus")
}
}
}
class CardUseCase {
// MARK: - Properties
var cardService = CardServiceImpl()
var membershipService = MembershipServiceImpl()
var analytics = AnalyticsImpl()
// MARK: - Fetch
func meansOfPayment(userId: String?,
completion: @escaping (MeansOfPaymentEntity?, Error?) -> Void) {
analytics.track(event: "did_show_meansOfPayment_screen")
if let userId = userId {
membershipService.membership(userId: userId) { membership, error in
if let error = error {
completion(nil, error)
return
}
if let membershipId = membership?.id {
self.cardService.card(membershipId: membershipId) { card, error in
if let error = error {
completion(nil, error)
return
}
let meansOfPaymentEntity = MeansOfPaymentEntity()
meansOfPaymentEntity.card = card
meansOfPaymentEntity.membership = membership
completion(meansOfPaymentEntity, nil)
}
}
}
}
}
}
// MARK: - DATA LAYER
// MARK: Data
// Membership and Card are Data model
enum CardLevel {
case standard
case plus
}
class Card {
var id: String?
var name: String?
var cardLevel: CardLevel?
}
class Membership {
var id: String?
}
// MARK: Service
protocol CardService {
func card(membershipId: String,
completion: @escaping (Card?, Error?) -> Void)
}
protocol MembershipService {
func membership(userId: String,
completion: @escaping (Membership?, Error?) -> Void)
}
// MARK: - Third Party
protocol Analytics {
func track(event: String)
}A hint, if you're stuck
- Follow
completion. Count every path throughmeansOfPaymentand ask, for each one: is it called? Then ask the same of the spinner on the screen. - The
// MARK:comments promise layers. Read eachimportand each type a layer mentions, and ask which way the arrows point. - The service protocols exist. Who uses them?
card?.cardLevelis optional. What does thedefault:branch catch besides.plus?
The key — what I expect a senior to find
The compiler says nothing — it builds clean in Swift 5 and Swift 6 mode. Every finding is about behaviour and design, which is exactly what this round is for. By severity:
First, what the user feels
- Two paths never call
completion: the screen loads forever. IfuserIdisnil, or the membership comes back without anid(and without an error), the function just ends. No result, no error — the presenter's spinner never stops (run: both paths delivered zero callbacks). Every path through an async function must call its completion exactly once. (Entity?, Error?)allows nonsense. Four combinations, two of them illegal: bothnil, or both set. The snippet already produces one: a card service that answers(nil, nil)turns into a "success" entity with no card (run:entity=true card=false error=false). UseResult<MeansOfPaymentEntity, Error>— one value, success or failure.- The icon lies when there's no card.
switch card?.cardLevelwithdefault:sendsnil— and any level added later, say.metal— to the Plus icon. A user with no card sees a Plus card. Switch exhaustively over the non-optional level, so the compiler flags a new case. UIImage!is a crash in waiting.UIImage(named:)returnsnilwhen the asset is missing or renamed, and the implicitly unwrapped return turns that into a crash wherever it's read.
Then the architecture — SOLID and the dependency rule
- The domain imports UIKit. An entity that returns a
UIImageand knows asset names is presentation code living in the domain. The dependency rule says inner layers know nothing about outer ones. MapcardLevelto an image in the presenter; the domain stays pure Swift, testable on any platform without a simulator. - Dependency inversion, half done.
CardServiceandMembershipServiceare protocols — and the use case ignores them and buildsCardServiceImpl(),MembershipServiceImpl()andAnalyticsImpl()itself. That's the whole testability problem in three lines: no test can make the membership call fail. Inject the protocols throughinit. The seam already exists; it's just not used. - Analytics in the use case: wrong place, wrong moment.
did_show_meansOfPayment_screenis a screen event, so it belongs to the presenter (on appear). Here it fires on every fetch — every retry or refresh — and even when there's no user and nothing is shown (run: it fired on all three failed paths). Single responsibility: fetching doesn't also report screen views. - The domain depends on data models.
MeansOfPaymentEntityholdsMembershipandCard, which the snippet itself labels data models. Clean architecture points the other way: the domain owns its types, the data layer maps DTOs into them. Say it, and judge whether it's worth it for two small types. - Everything is optional and mutable.
id: String?— an id that can be missing is how the silent path in finding 1 exists. An entity built withMeansOfPaymentEntity()then filled in can be half-empty forever. Make entities structs with non-optionallets, built complete or not at all. userId: String?pushes a question into the wrong layer. Why would a signed-out screen ask for means of payment? Make itStringand let the caller decide what "not signed in" means.
Then the API shape and naming
- Errors leave untyped. The presenter gets a raw
Errorand can't tell "no membership" (a normal state for some users? ask the PM) from "network down". Name the dead ends. CardUseCasedoesn't say what it does. It fetches a member's means of payment —GetMeansOfPaymentUseCase. The screen isPaymentMethodViewController, the presenterMeansOfPaymentPresenter: one concept, two names. Pick one.- Classes that should be
final, properties that should beprivate.var cardServiceis publicly replaceable after init — the opposite of injection. - Which thread does
completionrun on? Nothing says. The presenter will update UI with it. Document it, or hop to main in the presenter.
What's right — say it: the two calls are correctly sequential (the card needs the membership id), errors from each service are propagated, and the layers exist. This is a codebase that meant well; the review is about making it keep its promises.
The one-minute summary to end on: "Two paths never call back, so the screen can spin forever; the domain reaches into UIKit and builds its own services, so the protocols that would make it testable go unused; and the result type lets impossible states through — fix those three and the rest is naming."
The idea behind it
An async function makes a promise: "I will call you back exactly once." With completion handlers, the compiler doesn't check it. Every if let without an else, every early return, is a path where the promise can quietly break. Two habits keep it: turn each if let into a guard … else { completion(.failure(…)); return }, and use Result, so "success with nothing" and "failure with a value" can't be written at all. (With async throws the compiler does check it — every path must return or throw. That's the strongest reason to migrate.)
Clean architecture is mostly one rule: dependencies point inwards. Picture three rings: data on the outside, presentation beside it, the domain in the middle. The domain is plain Swift that describes the business — "a member has a membership and a card". It must not know about UIImage, asset names, analytics SDKs or …Impl classes, because then a change in any of them reaches the business logic, and testing the business logic needs all of them.
The protocols are how the arrows turn around. The domain says "I need something that can fetch a card" (CardService); the data layer provides it; someone outside both — the composition root — plugs them together. Here the protocols were written, then bypassed by = CardServiceImpl(). It's like installing a power socket and then wiring the lamp straight into the wall: the socket is there, but you still can't plug in anything else.
The fix
Keep completion handlers — that's the snippet's shape. Change only what the findings need. The seam is the two protocols the code already had; no new protocol is added.
import Foundation // key 5: no UIKit in the domain
struct MeansOfPaymentEntity { // key 9: a value, built complete
let membership: Membership
let card: Card
}
enum MeansOfPaymentError: Error, Equatable { // keys 1, 11: every dead end has a name
case missingUser
case noMembership
case noCard
}
final class CardUseCase {
private let cardService: CardService // key 6: the protocols that were
private let membershipService: MembershipService // already there, now injected
init(cardService: CardService, membershipService: MembershipService) {
self.cardService = cardService
self.membershipService = membershipService
}
func meansOfPayment(userId: String?,
completion: @escaping (Result<MeansOfPaymentEntity, Error>) -> Void) { // key 2
guard let userId else { return completion(.failure(MeansOfPaymentError.missingUser)) } // key 1
membershipService.membership(userId: userId) { [cardService] membership, error in
if let error { return completion(.failure(error)) }
guard let membership, let membershipId = membership.id else {
return completion(.failure(MeansOfPaymentError.noMembership)) // key 1
}
cardService.card(membershipId: membershipId) { card, error in
if let error { return completion(.failure(error)) }
guard let card else { return completion(.failure(MeansOfPaymentError.noCard)) } // key 2
completion(.success(MeansOfPaymentEntity(membership: membership, card: card)))
}
}
}
}And in the presenter, the two things that left the domain:
// key 7: a screen event, fired once when the screen appears
func viewDidAppear() { analytics.track(event: "did_show_meansOfPayment_screen") }
// keys 3, 4, 5: exhaustive, non-optional, no force unwrap; the view decides what "no image" means
func iconName(for level: CardLevel) -> String {
switch level {
case .standard: "ic_card_standard"
case .plus: "ic_card_plus"
}
}Said out loud, not coded: make userId non-optional (key 10); give the domain its own models and map from the data layer (key 8); rename to GetMeansOfPaymentUseCase and align the screen's names (key 12); ask the PM whether "no card yet" is an error or a screen state — if it's a state, it belongs in the entity (card: Card?), not in MeansOfPaymentError; and move to async throws, where the compiler enforces "every path returns".
Why each piece:
guard … else { completion(…) }on every branch — each dead end now reports, so the presenter can stop the spinner and show something.Result— the entity exists only on success, with both values in it. The(nil, nil)"success" can't be written any more.[cardService]instead ofself.— the inner call needs the service, not the use case. It also means nothing keeps the use case alive just to finish a request.- Analytics leaves, not injected — once it moves to the presenter, the use case doesn't need it at all. Fewer dependencies beat a better-injected one.
Now write the tests
"Good. There's no QA here, so: what would you test before this ships?"
What I'd test, and why
- A membership without an id fails instead of hanging — the bug that would have reached users: exactly one callback, with
.noMembership, and the card service never called. - No user fails without calling the network — one callback,
.missingUser, zero requests. - The membership id is what fetches the card — the happy path, and proof the two calls are wired in the right order with the right value.
- A card error reaches the caller once — errors from the second call aren't swallowed or doubled.
Each test collects every result the use case delivers, so "never called back" (count == 0) and "called twice" (count == 2) both fail loudly.
The seam. The two service protocols, with fakes that answer immediately with whatever the test sets and record what they were asked. Because the fakes call back synchronously, the tests need no waiting at all.
import Foundation
import Testing
/// Answers straight away with whatever the test set, and records what it was asked.
final class FakeMembershipService: MembershipService {
var answer: (Membership?, Error?) = (nil, nil)
private(set) var requestedUserIds: [String] = []
func membership(userId: String, completion: @escaping (Membership?, Error?) -> Void) {
requestedUserIds.append(userId)
completion(answer.0, answer.1)
}
}
final class FakeCardService: CardService {
var answer: (Card?, Error?) = (nil, nil)
private(set) var requestedMembershipIds: [String] = []
func card(membershipId: String, completion: @escaping (Card?, Error?) -> Void) {
requestedMembershipIds.append(membershipId)
completion(answer.0, answer.1)
}
}
struct CardUseCaseTests {
let memberships = FakeMembershipService()
let cards = FakeCardService()
var useCase: CardUseCase { CardUseCase(cardService: cards, membershipService: memberships) }
/// Runs the use case and returns every result it delivered, so a test can see "never" and "twice".
func results(for userId: String?) -> [Result<MeansOfPaymentEntity, Error>] {
var delivered: [Result<MeansOfPaymentEntity, Error>] = []
useCase.meansOfPayment(userId: userId) { delivered.append($0) }
return delivered
}
@Test
func aMembershipWithoutAnIdFailsInsteadOfHanging() throws {
let membership = Membership() // id is nil: the original never called back
memberships.answer = (membership, nil)
let delivered = results(for: "user-1")
#expect(delivered.count == 1)
#expect(throws: MeansOfPaymentError.noMembership) { try delivered[0].get() }
#expect(cards.requestedMembershipIds.isEmpty)
}
@Test
func noUserFailsWithoutCallingTheNetwork() throws {
let delivered = results(for: nil)
#expect(delivered.count == 1)
#expect(throws: MeansOfPaymentError.missingUser) { try delivered[0].get() }
#expect(memberships.requestedUserIds.isEmpty)
}
@Test
func theMembershipIdIsUsedToFetchTheCard() throws {
let membership = Membership(); membership.id = "m-42"
let card = Card(); card.cardLevel = .plus
memberships.answer = (membership, nil)
cards.answer = (card, nil)
let delivered = results(for: "user-1")
let entity = try #require(delivered.first).get()
#expect(delivered.count == 1)
#expect(memberships.requestedUserIds == ["user-1"])
#expect(cards.requestedMembershipIds == ["m-42"])
#expect(entity.card === card && entity.membership === membership)
}
@Test
func aCardErrorReachesTheCallerOnce() throws {
struct Offline: Error {}
let membership = Membership(); membership.id = "m-42"
memberships.answer = (membership, nil)
cards.answer = (nil, Offline())
let delivered = results(for: "user-1")
#expect(delivered.count == 1)
#expect(throws: Offline.self) { try delivered[0].get() }
}
}All four pass. Run the first two against the original and they can't even be written — the original builds its own services, so there's nothing to fake. That's finding 6, proved.
What I'd ask next
- "Rewrite
meansOfPaymentwith async/await." —func meansOfPayment(userId: String) async throws -> MeansOfPaymentEntity: twotry awaitcalls and twoguard … else { throw … }. The compiler now rejects a path that neither returns nor throws, so finding 1 can't come back. - "Could the two calls run in parallel?" — Not as designed: the card needs the membership id. If this screen is slow, the better question is for the backend — one endpoint, "means of payment for user", so the app makes one round trip.
- "Is 'no membership' an error?" — Depends on the product. If some users legitimately have none, it's a screen state ("Get a card"), not a failure. That's a question for the PM, and the answer decides whether it lives in the entity or in the error enum.
- "Where should
PaymentMethodViewControllerget its presenter, and the presenter its use case?" — From a composition root (a factory or coordinator) that builds the real services once. Nothing in the chain says…Impl()except that root. - "How would you test the presenter?" — The same way: inject the use case behind a small protocol (or a closure), fake its result, and check what the presenter told the view — including that the spinner stops on every failure.
- "Swift 6 doesn't complain about any of this. Should it?" — The completion closures aren't
@Sendable, so the compiler trusts them. Once these services really call back on a background queue, marking the closures@Sendable(or moving toasync) makes the compiler check what crosses threads.