Architecture & Agility: 06 - Example Feature implementation
Implementing an example feature in Core|UI
Let’s have a look at how we can implement a feature in Core|UI and add a feature most apps have today: Asking for an app review.
This feature will actually resemble a sink: it will react to certain messages, but will not respond with any.
To qualify for a review request, the user must have created at least 3 todo items or must have checked off one.
First we create the Feature-function that will represent this functionality
func createAppReviewFeature() -> Input { // no output -> Sink
return { msg in
}
}
and we create one for the appcore
func createAppCore(
store: StateStore,
receivers: [Input],
rootHandler: @escaping Output) -> Input
{
store.updated {
#if targetEnvironment(simulator)
print("\(store.state())")
#endif
}
let reviewGateway = StoreKitReviewGateway(store: store)
let features: [Input] = [
createTodoListFeature (store: store, output: rootHandler),
createTaggingFeature (store: store, output: rootHandler),
createJournalingFeature(store: store, output: rootHandler),
createAppReviewFeature () // <-- added here
]
return { msg in
(receivers + features).forEach { $0(msg) }
}
}
We need a message
enum Message: Equatable {
case todo(_Todo)
case journal(_Journal)
case tagging(_Tagging)
case appReview(_AppReview) // <-- added
enum _Todo: Equatable { /* … */}
enum _Journal: Equatable { /* … */}
enum _Tagging: Equatable { /* … */}
enum _AppReview { // <-- added
case requestReview
}
}
Now we need to add our UseCase, which will also use a gateway
struct AppReviewRequester: UseCase {
enum Request { case requestShowReview }
enum Response {}
let reviewGateway: ReviewGateway
func request(_ request: Request) {
switch request { case .requestShowReview: show() }
}
private func show() { reviewGateway.show() }
typealias RequestType = Request
typealias ResponseType = Response
}
with
protocol ReviewGateway {
func show()
}
and implemented for StoreKit
import StoreKit
struct StoreKitReviewGateway: ReviewGateway {
let store: StateStore
func show() {
if
store.state().items.count > 2
|| store.state().items.filter({ $0.completed == true }).count > 0
{
let deadlineTime = DispatchTime.now() + .seconds(5)
DispatchQueue.main.asyncAfter(deadline: deadlineTime) {
if let windowScene = UIApplication.shared.windows.first?.windowScene { SKStoreReviewController.requestReview(in: windowScene) }
}
}
}
}
Now we connect everything by implementing the inner workings of the createAppReviewFeature function like
func createAppReviewFeature(reviewGateway: ReviewGateway) -> Input {
let reviewRequester = AppReviewRequester(reviewGateway: reviewGateway)
return { msg in
if case .appReview(.requestReview) = msg { reviewRequester.request(.requestShowReview) }
}
}
and pass a gateway in createAppStore()
let reviewGateway = StoreKitReviewGateway(store: store)
let features: [Input] = [
// ...
createAppReviewFeature (reviewGateway: reviewGateway)
]
And to test if we have set ip up correctly, we let SwiftUI send the message when the app starts
var body: some Scene { WindowGroup { ContentView( viewState: viewState, rootHandler: rootHandler ).onAppear {
self.rootHandler(.appReview(.requestReview))
}
and it works.
But we would like to have it a little different:
Instead of checking during app start, we want to do it reactive to the message flow. We could change our feature and listen to message of interest, but in my experience it can become quite confusing if you listen to messages from one feature inside another. Also for this feature it would mean that it would listen to messages with associated values, which constitutes an unnecessary dependency. One solution is to register a translating object as a receiver and provide it with an output so that it can add messages to it if it sees certain others.
@main
final
class TodosApp: App {
private let store: StateStore = createStore()
private lazy var viewState: ViewState = ViewState(store: store)
private lazy var rootHandler: (Message) -> ()
= createAppCore(store: store, receivers: [viewState.handle(msg:), messageExpander(output: { self.rootHandler($0) })], rootHandler: { self.rootHandler($0) })
var body: some Scene { WindowGroup { ContentView( viewState: viewState, rootHandler: rootHandler ) } }
}
func messageExpander(output: @escaping Output) -> Input {
return { msg in
if case .todo(.response(.wasAdded (_))) = msg { output(.appReview(.requestReview)) }
if case .todo(.response(.wasChecked(_))) = msg { output(.appReview(.requestReview)) }
}
}
For this example the messageExpander function shall do. It will return an input which we save in our receivers list and it will add messages if needed by calling the output callback.
What did we learn from this exercise?
- First of all: adding features is purely additive in this architecture.
We didn’t need to go through some jungle-like code paths and alter complex statements. - We didn’t even had to code anything where our todo items are changed. We just listen for the message that the change occurred. We are using true black boxes.
- We have seen that feature can resemble a Sink. The can as-well resemble a Source, ie chat inboxes, heart beat clock.
- It took me mere minutes to implement this. I have seen teams struggle for month for the same task.
- We can isolate other peoples libraries in a dependency that I usually call gateway, again, taken from Robert C. Martin. This increases portability immensely: By just reimplementing the gateways we can connect to different services, port to different OSs, connect to different UIs or alternate delivery mechanisms.
We can keep all of the AppCore (nearly) perfectly clean from other people codes: We exert maximal control over our code, which might very likely give us the extra-advantage we need in a highly competitive market.
Next…
In the next article I want to look at how to adapt this architecture for huge but also tiny projects.