DeclarativeHub / TheBinderArchitecture

A declarative architecture based on bindings
MIT License
148 stars 6 forks source link

Suggestions for flow from View back to Service #6

Open npvisual opened 5 years ago

npvisual commented 5 years ago

Description

At the bottom of the Binder section there are 3 rules mentioned :

  1. Assign Service data that is available at the binding time to the View Controller
  2. Bind Service data or events that are available asynchronously (as Signals/Observables) to the View Controller
  3. Observe user actions or user input from the View Controller with the instance methods of the Service

# 1 and # 2 are pretty straightforward as well as most situations for # 3. However for more complex scenarios, I am having some issues figuring out what to use and where to locate it.

Example

Let's assume we have a ScheduleService in the Business Logic portion of the application. We are able to retrieve schedules / events by calling APIs of a calendar backend and feeding that data to a Signal in the ScheduleService (let's say a LoadingSignal).

For example, something like :

public class EventService: DisposeBagProvider {
    public let randomSchedule: LoadingSignal<ScheduledEvent, ApplicationError>
    ...
    public init() {

        randomSchedule = ScheduledEvent
                        .getEvent(for: random)
                        .response(using: client)
                        .map { $0.event }
                        .toLoadingSignal()
    }
}
...(simplified version)

The View will show, for example, a UIDatePicker that represents the start date of the schedule if one is available or the current date / time if there's no existing schedule (a new one is being created)

The Binder will leverage the Signal from the ScheduleService to update that UIDatePicker based on new events coming in (say if another schedule is selected from a table view that lists available schedules).

        controller.start_dtPicker.reactive.date
            .bind(signal: eventService.randomSchedule
                .value()
                .map { $0.start_dt }
                .ignoreNil() )
            .dispose(in: controller.bag)

That will link the UIDatePicker display to the value of the start date from the scheduled event. No problems so far.

However what gets trickier, to me, is when the user starts modifying that start date via the UIDatePicker. The date is just one element of a Schedule object, which will, eventually, have to be saved back via another API call to the calendar backend.

But since I cannot feed that into the LoadingSignal from the ScheduleService, I need to implement either an intermediate Signal (or a Property) that will be used when a "Save" button is selected (since we don't want to trigger an API call for every change of the UIDatePicker).

Since there's probably more to customize in the Schedule object, that means I will have to somehow aggregate all those Signals together.

But where do those intermediate Signals belong ? Should they be declared in the ScheduleService or in the Binder itself (since they are a proxy between view components and the underlying service) ?

How to properly handle all those aggregated values ? Should it be an object made of Properties ? Or should be just collect the signals from the various UI components ?

Or maybe use a SafeReplayOneSubject<ScheduledEvent> where a new element is emitted every time an interaction happens in the view (i.e. changing the date twice in the UIDatePicker will emit 2 new ScheduledEvent), and then observe a tap of the Save button with a saveSchedule() that would use that SafeReplayOneSubject and call the API ? If so would the coupling still reside in the Binder ?

While everything else seems to flow pretty well from the Service to the View via the Binder, the opposing flow isn't as straightforward.

Any brilliant ideas ?

srdanrasic commented 5 years ago

If I understood you correctly, you have an event service that can provide and update an event and a view controller that can display and edit an event. That is a very interesting example because it touches multiple problems.

Here is one possible implementation. I've made it very verbose on purpose so that it can be easier understood.

Assuming we are working with the following:

struct ApplicationError: Error {}

struct ScheduledEvent {
    let name: String
    let date: Date
}

class EventService {
    var event: LoadingSignal<ScheduledEvent, ApplicationError>
    func updateEvent(_ event: ScheduledEvent) -> LoadingSignal<Void, ApplicationError>
}

class EventViewController: UIViewController, LoadingStateListener {

    enum UserInputError: Swift.Error {
        case missingName
    }

    let datePicker = UIDatePicker()
    let nameTextField = UITextField()
    let saveButton = UIButton()
}

This is how the binder implementation could look like.

let eventService: EventService
let viewController = EventViewController()

// Bind service data to the view.
// When the data in the signal is supposed to fill multiple views,
// we can bind the signal to a common parent of the views, like
// the view controller itself, and then populate the individial views.
eventService.event
    .consumeLoadingState(by: viewController)
    .bind(to: viewController) { viewController, event in
        viewController.datePicker.date = event.date
        viewController.nameTextField.text = event.name
    }

// This signal contains the data needed to update the event.
let eventDetails = combineLatest(
    viewController.datePicker.reactive.date,
    viewController.nameTextField.reactive.text
)

// This signal fires when the user taps the save button.
let saveButtonTap = viewController.saveButton.reactive.tap

// We can create an event instance from the event details. However, that will not
// always be possible since something could be wrong with the input data.
// We can use the result type to express operations that can fail.
let eventOrInputError = eventDetails.map { (dateAndName) -> Result<ScheduledEvent, UserInputError> in
    let date = dateAndName.0
    if let name = dateAndName.1 {
        return .success(ScheduledEvent(name: name, date: date))
    } else {
        return .failure(.missingName)
    }
}

// At this point we have a signal that fires events when the user taps the save button and
// a signal that either fires an event constructed from the input data or an error
// describing what is wrong with the input data.

// What can happen when the save button is tapped? More than one would usually imagine:
//   1. Something could be wrong with user input - we need to indicate that to the user
//   2. User input is valid and the event service is used to update the event on the backend
//   2.1. Update request starts - we need to display loading indication to the user
//   2.2. Update request succeeds - we need to inidicate success to the user
//   2.3. Update request fails - we need to show the error to the user

// Let's model those outcomes with a simple enum:

enum EventUpdateResult {
    case inputError(UserInputError)
    case eventUpdateState(LoadingState<Void, ApplicationError>)
}

// Now that we have the possible outcomes spelled out, let's implement save button logic:

// We start with the save button taps...
let eventUpdateResult = saveButtonTap
    // ...and we flat map them into the possible events
    .flatMap(.latest) { eventOrInputError }
    // Now we have a signal whose value is either an event or an input error.
    // We have to switch on the value and continue with the appropriate action. Another flatMap.
    .flatMap(.latest) { (eventOrInputError) -> SafeSignal<EventUpdateResult> in
        // Here is where we branch into the two possible outcomes...
        switch eventOrInputError {
        case .success(let event):
            // ...on success we call the service and then wrap the loading state into our enum
            return eventService.updateEvent(event).map { .eventUpdateState($0) }
        case .failure(let error):
            // ...on failure we just propage the input error case
            return Signal(just: .inputError(error))
        }
    }

// Alright. At this point we have a signal that represents the event update
// result triggerd by tapping the save button. So far we have just expressed a
// desired behaviour. To make all this work, all that is left is to observe or
// bind our eventUpdateResult signal.

// Who handles the side effects of our actions? View controller, of course:

eventUpdateResult.bind(to: viewController) { viewController, result in
    switch result {
    case .inputError(let inputError):
        viewController.indicateInputError(inputError)
    case .eventUpdateState(let loadingState):
        switch loadingState {
        case .loading:
            viewController.indicateEventUpdateInProgress()
        case .failed(let error):
            viewController.displayErrorDialog(error)
        case .loaded: // the event has been successfully updated
            viewController.dismiss(animated: true)
        }
    }
}

What's nice about this solution is that the whole logic around saving/updating is represented by one signal: eventUpdateResult. Observing (binding) that signal gives us all possible outcomes of the save action. It's obvious what can happen when saving the event just by looking at this single bind(to:) method.

In imperative paradigm logic can simplified by splitting it into multiple functions. In functional-reactive one can split long signal operator chains into simpler ones as we did with signals like eventDetails, saveButtonTap and eventOrInputError. We then used those to build more complex behaviours.

Hope this gives you some ideas on how to solve problems like these.

npvisual commented 5 years ago

@srdanrasic , thank so much for the really detailed response. 🤯

This really helps me to understand what to include in the Binder and what to move to the Service area.

At this point I am even tempted to remove the ScheduledEvent(name: name, date: date) notion and just purely push the bare application logic data to the eventService.updateEvent(). I.e. :

        return eventService.saveEvent(name, date)

This way I can let the Event Service deal with how the underlying data is modeled and just provide a method that will use the outflow / outputs of the Binder. Would that make sense ?

As for the details, I was mostly successful in refactoring my code along the lines you indicated. However, I am a little confused about that last part :

eventUpdateResult.bind(to: viewController) { viewController, result in
    switch result {
    case .inputError(let inputError):
        viewController.indicateInputError(inputError)
    case .eventUpdateState(let loadingState):
        switch loadingState {
        case .loading:
            viewController.indicateEventUpdateInProgress()
        case .failed(let error):
            viewController.displayErrorDialog(error)
        case .loaded: // the event has been successfully updated
            viewController.dismiss(animated: true)
        }
    }
}

I am probably missing some fundamental knowledge of LoadingState and LoadingSignal, so I apologize if this is basic understanding... So I guess the focus is on :

    func updateEvent(_ event: ScheduledEvent) -> LoadingSignal<Void, ApplicationError>

and how we're wrapping the LoadingState to pass it inside the enum...

        case .success(let event):
            // ...on success we call the service and then wrap the loading state into our enum
            return eventService.updateEvent(event).map { .eventUpdateState($0) }

Shouldn't we pass the LoadingSignal altogether so that we can use .consumeLoadingState as the signal gets updated ?

As it stands right now it seems, to me, that we will only get an update for eventUpdateResult when the button is pressed and that will, most likely, only trigger .loading loading state forever... Am I missing something ?

carsonxyz commented 5 years ago

Is there any update to this question? I find myself wondering the same thing. Cheers!