Architecture With Nico

Practical software architecture and design for iOS engineers.

Complex Forms Should Not Make Your ViewModel the Use Case

Complex forms are one of the fastest ways to overload a ViewModel.

The screen starts with a few fields.

Then one field depends on another. Then validation becomes conditional. Then the submit button needs loading state. Then one field opens a selection screen. Then the backend wants a command that does not look like the UI. Then there is analytics, autosave, server validation, and a success route.

After a while, the ViewModel is no longer preparing state for the view.

It is the form model, the validator, the mapper, the submit use case, the analytics policy, and sometimes the navigation flow.

That is too much.

The ViewModel can own the editable draft. It should not own the meaning of submitting it.

This is the same failure mode I wrote about in When MVVM Goes Wrong in SwiftUI, but complex forms make it easier to see because every responsibility arrives through the same screen.

#The Tempting Shape

This kind of ViewModel is easy to write:

@MainActor
final class CheckoutViewModel: ObservableObject {
    @Published var firstName = ""
    @Published var lastName = ""
    @Published var street = ""
    @Published var city = ""
    @Published var postalCode = ""
    @Published var useShippingAsBilling = true
    @Published var acceptsTerms = false
    @Published var errorMessage: String?
    @Published var isLoading = false
    @Published var path = NavigationPath()

    private let apiClient = CheckoutAPIClient()
    private let analytics = Analytics.shared

    func submit() async {
        guard acceptsTerms else {
            errorMessage = "You need to accept the terms."
            return
        }

        let request = CheckoutRequest(
            firstName: firstName,
            lastName: lastName,
            street: street,
            city: city,
            postalCode: postalCode,
            billingSameAsShipping: useShippingAsBilling
        )

        isLoading = true
        analytics.track("checkout_started")

        do {
            try await apiClient.placeOrder(request)
            analytics.track("checkout_succeeded")
            path.append(CheckoutRoute.confirmation)
        } catch {
            analytics.track("checkout_failed")
            errorMessage = "Could not place your order."
            isLoading = false
        }
    }
}

This is not bad because it has many properties.

Forms have many properties.

The problem is that every decision has collapsed into one type:

  • editable field state
  • validation
  • mapping into backend input
  • networking
  • analytics
  • loading and error rendering
  • navigation

The ViewModel has become the feature.

#Split the Models

A complex form usually has at least three different models hiding inside it.

The first is the editable draft:

struct CheckoutDraft: Equatable {
    var firstName: String
    var lastName: String
    var street: String
    var city: String
    var postalCode: String
    var useShippingAsBilling: Bool
    var acceptsTerms: Bool
}

This is the shape of the UI.

It is allowed to contain strings, temporary choices, toggles, incomplete values, and UI-specific decisions. It represents what the user is currently editing.

The second is render state:

struct CheckoutViewState: Equatable {
    var firstName: String
    var lastName: String
    var street: String
    var city: String
    var postalCode: String
    var useShippingAsBilling: Bool
    var acceptsTerms: Bool
    var fieldErrors: [CheckoutField: String]
    var formError: String?
    var isSubmitting: Bool
}

This is what the view needs to render.

The third is the command:

struct PlaceOrderCommand: Equatable {
    let customerName: CustomerName
    let shippingAddress: Address
    let billingAddress: Address
    let acceptsTerms: Bool
}

This is the input to the application operation.

If you use use cases, this is the kind of input the use case should receive. The use case should not receive half-edited strings just because the form happens to be built from text fields. I wrote more about that boundary in Use Cases in iOS Apps.

The draft belongs to the UI. The command belongs to the operation.

Different UIs can have different drafts and still map to the same command. A full checkout form may ask for every address field. An express checkout form may ask the user to select a saved address. A support-assisted checkout flow may prefill most values and ask for confirmation.

Those screens should not be forced to share the same draft.

They can share the same operation input.

#The ViewModel Owns the Draft

The ViewModel can own the editable draft because the draft is part of the screen's editing state.

enum CheckoutFieldChange {
    case firstName(String)
    case lastName(String)
    case street(String)
    case city(String)
    case postalCode(String)
    case useShippingAsBilling(Bool)
    case acceptsTerms(Bool)
}

enum CheckoutScreenEvent {
    case completed
}

@MainActor
final class CheckoutViewModel: ObservableObject, CheckoutFormOutput {
    @Published private(set) var state: CheckoutViewState

    private var draft: CheckoutDraft

    var onEvent: (CheckoutScreenEvent) -> Void = { _ in }

    var currentDraft: CheckoutDraft {
        draft
    }

    init(draft: CheckoutDraft) {
        self.draft = draft
        self.state = CheckoutViewState(draft: draft)
    }

    func update(_ change: CheckoutFieldChange) {
        draft = draft.applying(change)
        state = CheckoutViewState(draft: draft)
    }

    func checkoutValidationFailed(_ validation: CheckoutValidation) {
        state = CheckoutViewState(
            draft: draft,
            validation: validation
        )
    }

    func checkoutSubmissionStarted() {
        state = state.submitting()
    }

    func checkoutSubmissionFailed(_ message: String) {
        state = state.failed(message)
    }

    func checkoutSubmissionSucceeded() {
        state = state.succeeded()
        onEvent(.completed)
    }
}

Notice what is missing.

There is no submit() method.

The ViewModel can receive field changes. It can keep the draft. It can turn the draft and output events into render state.

It does not decide what submitting the draft means.

#Validation Gets a Name

Validation should be separate when it becomes more than a local field check.

protocol CheckoutDraftValidating {
    func validate(_ draft: CheckoutDraft) -> CheckoutValidation
}

struct CheckoutValidation: Equatable {
    let fieldErrors: [CheckoutField: String]
    let formError: String?

    var isValid: Bool {
        fieldErrors.isEmpty && formError == nil
    }
}

struct CheckoutDraftValidator: CheckoutDraftValidating {
    func validate(_ draft: CheckoutDraft) -> CheckoutValidation {
        var errors: [CheckoutField: String] = [:]

        if draft.firstName.isEmpty {
            errors[.firstName] = "First name is required."
        }

        if draft.postalCode.isEmpty {
            errors[.postalCode] = "Postal code is required."
        }

        if !draft.acceptsTerms {
            errors[.acceptsTerms] = "You need to accept the terms."
        }

        return CheckoutValidation(
            fieldErrors: errors,
            formError: nil
        )
    }
}

The validator answers one question:

Can this draft be submitted?

It does not render the view. It does not call the use case. It does not navigate.

#Mapping Gets a Name Too

The ViewModel should not translate the draft into the operation command.

That translation crosses a boundary:

UI-specific editable state
    -> application operation input

Give it a mapper.

protocol CheckoutCommandMapping {
    func command(from draft: CheckoutDraft) -> PlaceOrderCommand
}

struct CheckoutCommandMapper: CheckoutCommandMapping {
    func command(from draft: CheckoutDraft) -> PlaceOrderCommand {
        let shippingAddress = Address(
            street: draft.street,
            city: draft.city,
            postalCode: draft.postalCode
        )

        let billingAddress = draft.useShippingAsBilling
            ? shippingAddress
            : Address.empty

        return PlaceOrderCommand(
            customerName: CustomerName(
                first: draft.firstName,
                last: draft.lastName
            ),
            shippingAddress: shippingAddress,
            billingAddress: billingAddress,
            acceptsTerms: draft.acceptsTerms
        )
    }
}

This is not just moving data around.

Mapping can include parsing strings into value objects, resolving selected options, removing UI-only state, applying conditional fields, and shaping data for the operation.

That meaning should not hide inside the ViewModel.

#Submitting Is a Form Operation

The submit intent needs a home.

Not the View.

Not the ViewModel.

Not the factory.

A useful place is a form operation:

protocol CheckoutFormOutput: AnyObject {
    func checkoutValidationFailed(_ validation: CheckoutValidation)
    func checkoutSubmissionStarted()
    func checkoutSubmissionFailed(_ message: String)
    func checkoutSubmissionSucceeded()
}

protocol PlaceOrderUseCase {
    func execute(_ command: PlaceOrderCommand) async throws
}

final class SubmitCheckoutForm {
    private let validator: CheckoutDraftValidating
    private let mapper: CheckoutCommandMapping
    private let placeOrder: PlaceOrderUseCase
    private weak var output: CheckoutFormOutput?

    init(
        validator: CheckoutDraftValidating,
        mapper: CheckoutCommandMapping,
        placeOrder: PlaceOrderUseCase,
        output: CheckoutFormOutput
    ) {
        self.validator = validator
        self.mapper = mapper
        self.placeOrder = placeOrder
        self.output = output
    }

    func execute(_ draft: CheckoutDraft) async {
        let validation = validator.validate(draft)

        guard validation.isValid else {
            await MainActor.run {
                output?.checkoutValidationFailed(validation)
            }
            return
        }

        let command = mapper.command(from: draft)

        await MainActor.run {
            output?.checkoutSubmissionStarted()
        }

        do {
            try await placeOrder.execute(command)

            await MainActor.run {
                output?.checkoutSubmissionSucceeded()
            }
        } catch {
            await MainActor.run {
                output?.checkoutSubmissionFailed(
                    "Could not place your order."
                )
            }
        }
    }
}

This type coordinates the form submission.

It validates the draft, maps it to a command, calls the use case, and reports outcomes.

That is a real responsibility. It deserves a name.

#The Route Wires Intent

The SwiftUI view should not know this whole story.

It renders state and reports user intent:

struct CheckoutView: View {
    let state: CheckoutViewState
    let onChange: (CheckoutFieldChange) -> Void
    let onSubmit: () async -> Void

    var body: some View {
        Form {
            TextField(
                "First name",
                text: Binding(
                    get: { state.firstName },
                    set: { onChange(.firstName($0)) }
                )
            )

            Toggle(
                "Use shipping as billing",
                isOn: Binding(
                    get: { state.useShippingAsBilling },
                    set: { onChange(.useShippingAsBilling($0)) }
                )
            )

            Button("Place order") {
                Task {
                    await onSubmit()
                }
            }
            .disabled(state.isSubmitting)
        }
    }
}

A route or container connects the view to the ViewModel and submit operation:

struct CheckoutRoute: View {
    @ObservedObject var viewModel: CheckoutViewModel
    let onSubmit: (CheckoutDraft) async -> Void

    var body: some View {
        CheckoutView(
            state: viewModel.state,
            onChange: viewModel.update,
            onSubmit: {
                await onSubmit(viewModel.currentDraft)
            }
        )
    }
}

The route does not validate. It does not map. It does not call the use case directly.

It adapts the current screen state into an intent for the composed operation.

#The Factory Builds the Graph

A tempting factory version wires the feature and translates completion into a callback:

struct CheckoutFeatureFactory {
    let placeOrder: PlaceOrderUseCase

    func makeCheckoutRoute(
        onCompleted: @escaping () -> Void
    ) -> CheckoutRoute {
        let viewModel = CheckoutViewModel(
            draft: CheckoutDraft.empty
        )

        let submitForm = SubmitCheckoutForm(
            validator: CheckoutDraftValidator(),
            mapper: CheckoutCommandMapper(),
            placeOrder: placeOrder,
            output: viewModel
        )

        viewModel.onEvent = { event in
            switch event {
            case .completed:
                onCompleted()
            }
        }

        return CheckoutRoute(
            viewModel: viewModel,
            onSubmit: submitForm.execute
        )
    }
}

This example still has a problem.

The factory is interpreting a screen event.

That switch belongs in a flow or parent, not in the factory. The factory should wire callbacks, not decide navigation meaning.

A cleaner version is:

struct CheckoutFeatureFactory {
    let placeOrder: PlaceOrderUseCase

    func makeCheckoutRoute(
        onEvent: @escaping (CheckoutScreenEvent) -> Void
    ) -> CheckoutRoute {
        let viewModel = CheckoutViewModel(
            draft: CheckoutDraft.empty
        )

        viewModel.onEvent = onEvent

        let submitForm = SubmitCheckoutForm(
            validator: CheckoutDraftValidator(),
            mapper: CheckoutCommandMapper(),
            placeOrder: placeOrder,
            output: viewModel
        )

        return CheckoutRoute(
            viewModel: viewModel,
            onSubmit: submitForm.execute
        )
    }
}

The flow interprets the event:

@MainActor
final class CheckoutFlow: ObservableObject {
    enum Route: Hashable {
        case confirmation
    }

    @Published var path: [Route] = []

    private let factory: CheckoutFeatureFactory

    init(factory: CheckoutFeatureFactory) {
        self.factory = factory
    }

    func makeCheckoutRoute() -> CheckoutRoute {
        factory.makeCheckoutRoute(onEvent: handle)
    }

    private func handle(_ event: CheckoutScreenEvent) {
        switch event {
        case .completed:
            path.append(.confirmation)
        }
    }
}

Now each decision has a clear owner.

That keeps the same direction as Unidirectional Data Flow in SwiftUI Apps: view intent goes into a composed operation, outputs come back to the ViewModel as state, and the flow owns navigation.

#Testing Becomes Easier to Aim

This split gives tests better targets.

Test draft editing in the ViewModel:

func test_updateFirstName_updatesRenderedState() {
    let viewModel = CheckoutViewModel(draft: .empty)

    viewModel.update(.firstName("Nico"))

    XCTAssertEqual(viewModel.state.firstName, "Nico")
}

Test validation without SwiftUI:

func test_validate_requiresTerms() {
    let validator = CheckoutDraftValidator()

    let validation = validator.validate(
        CheckoutDraft.empty
    )

    XCTAssertEqual(
        validation.fieldErrors[.acceptsTerms],
        "You need to accept the terms."
    )
}

Test mapping without the ViewModel:

func test_mapper_buildsCommandFromDraft() {
    let mapper = CheckoutCommandMapper()

    let command = mapper.command(
        from: .completeExample
    )

    XCTAssertEqual(command.customerName.first, "Nico")
}

Test submit coordination without rendering a view:

func test_submit_invalidDraft_reportsValidationFailure() async {
    let output = RecordingCheckoutFormOutput()
    let submit = SubmitCheckoutForm(
        validator: AlwaysInvalidCheckoutValidator(),
        mapper: FailingCheckoutCommandMapper(),
        placeOrder: UncalledPlaceOrderUseCase(),
        output: output
    )

    await submit.execute(.empty)

    XCTAssertEqual(output.events, [.validationFailed])
}

The tests now follow the architecture.

You do not need one giant ViewModel test that knows every detail of validation, mapping, submission, analytics, and navigation.

That is the same testing idea from Testing Architecture, Not Implementation Details: test each promise at the boundary where it belongs.

#What to Remember

A complex form is not one problem.

It is several problems that often appear on the same screen.

  • The draft is UI-specific editable state.
  • The view state is renderable data.
  • The validator decides whether the draft can be submitted.
  • The mapper turns a valid draft into operation input.
  • The form operation coordinates validation, mapping, and submission.
  • The use case performs the application operation.
  • The ViewModel owns draft editing and maps outputs into state.
  • The flow owns navigation.

The ViewModel can hold the draft.

It should not become the thing that decides what the draft means.

That is the difference between a form that is large and a form that is hard to change.