Search code examples
swiftfunctionswiftuiarchitecturedeclarative

SwiftUI - where should logic live architecturally speaking


I'm pretty new to SwiftUI and have a question regarding where logic should live. Obviously with SwiftUI being declarative and based on states, I want to avoid having big chunks of logic-based code in a SwiftUI view struct. However, in my example I have a button which, when pressed, should trigger the following POST API call:

private func submitIncidentForm(selectedIncident: IncidentReasonResponse) {
    showError = false
    guard let incidentId = selectedIncident.id else { return }
    let args = ["order_id": orderId]
    let body = IncidentRequest(reason: incidentId, employeeId: employeeId, pilotMessage: self.comments)
    
    API.client.post(Endpoint.incident, with: args, using: .put, posting: body, expecting: IncidentResponse.self) { success, response in
        DispatchQueue.asyncMain {
            
            switch success {
            case.success(_):
                if let response = response {
                    self.existingIncidents.append(response)
                    self.selectedIncident = nil
                    self.comments = ""
                }
                
            case .failure(_, _):
                showError = true
            }
        }
    }
}

In an ideal SwiftUI architecture, where would this live and be called from?


Solution

  • First things first: there is no ideal architecture.

    IMHO, a good architecture lets you enough freedom to express simple problems in a simple way with high locality, instead forcing you to write a couple glue classes whose role is to provide an abstraction, but then also adding complexity just for separating concerns located in different files.

    Nonetheless, in your case it makes sense to use one of the patterns MVVM or MVI.

    The user expresses its "intent" by tapping the "Submit Form" button, whose action calls the function submit(_ request: IncidentRequest):

    struct IncidentRequest { ... }
    
    struct IncidentRequestView: View {
        let viewState: incidentRequestViewState
        let submit: (IncidentRequest) -> Void
    
        @State var incidentRequest: IncidentRequest = .init()
    
        var body: some View {
            Button("Submit Form") {
                submit(incidentRequest)
            }
        }
    }
    

    The closure submit and the viewState must be setup by the parent view!

    Note, that the view does not handle the submit request itself. It also does not and cannot mutate its View State, which represents everything the view needs to render itself.

    A View Model would now eventually receive this "intent" and process it:

    final class IncidentRequestViewModel: ObservableObject {
        @Published private(set) var viewState: IncidentRequestViewState = .init()
    
        func submit( _ request: IncidentRequest) { 
            ...
        }
    }
    

    The implementation of the submit function would then "translate" the "submit incident request" to a suitable form which can be send to the Model, which is ultimately responsible to perform the request. The Model is preferable a publisher and the View Model observes the model.

    For a clean architecture, the Model would be an abstraction for any concrete kind of thing that ultimately can perform the incident request. That is, it would not be an API which leaks implementation details like, that this is a network call, or that you write to Realm database or to CoreData, etc.

    Now, when you have your IncidentRequest View, your IncidentRequest ViewModel and your IncidentRequest Model, you need to assemble these components, "somewhere".

    When you employ an architecture like MVVM you would work on conventions and good practices to accomplish this. Your team might agree on using a dedicated parent view (SwiftUI) where you assemble the parts:

    struct IncidentRequestMainView: View {
    
        @StateObject var viewModel = 
            IncidentRequestViewModel(model: /* injected */)
    
        var body: some View {
            IncidentRequestView(
                incidentViewState: viewModel.viewState,
                submit: viewModel.submit)
        }
    
    }
    

    You might notice, that the actual complicated things which handle all the actions required to perform this request purely technically (aka Domain Logic) have been moved to the Model. That's where it belongs to, too.

    Also, the pure visual logic seems to be in the View Model (saying what to render - aka Presentation Logic) and the view (saying how to render). And this is IMHO the right places, too.

    And lastly, there is a dedicated component responsible to assemble all the parts.