Video #249: Tour of the Composable Architecture: Persistence
Episode: Video #249 Date: Sep 11, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep249-tour-of-the-composable-architecture-1-0-persistence

Description
We conclude the series by adding the final bit of functionality to our application: persistence. We’ll see how adding a dependency on persistence can wreak havoc on previews and tests, and all the benefits of controlling it.
Video
Cloudflare Stream video ID: 345fa47097e7ea2ba757d41c508f0d03 Local file: video_249_tour-of-the-composable-architecture-1-0-persistence.mp4 *(download with --video 249)*
References
- Discussions
- Composable Architecture
- Getting started with Scrumdinger
- 0249-tca-tour-pt7
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
It’s pretty cool to see just how easy it was to use Apple’s speech recognizer API in order to get a live feed of transcription data while running our meeting. And we could put all that logic in an effect so that our reducer can remain a simple function, and our state can remain a simple value type. Brandon
— 0:20
Now let’s actually do something with these transcripts. Speech recognition client
— 0:30
We want the transcript to be added to the meeting when a new meeting is added to the standup. Currently the way meetings are created is when the root-level app feature listens for a delegate action letting it know the meeting ended.
— 1:03
Well, what if we passed along the transcript right in the delegate action? switch delegate { case let .saveMeeting(transcript: transcript): …
— 1:14
Then we could pass it along when creating the meeting: state.path[id: id, case: /Path.State.detail]? .standup.meetings.insert( Meeting( id: self.uuid(), date: self.now, transcript: transcript ), at: 0 )
— 1:17
We just need to have that case hold onto the transcript: enum Delegate: Equatable { case saveMeeting(transcript: String) }
— 1:24
Then in the various places that we send that delegate action we need to attach the transcript, such as when confirming saving the meeting in the alert: return .run { [transcript = state.transcript] send in await send( .delegate(.saveMeeting(transcript: transcript)) ) await self.dismiss() }
— 2:03
Or when the timer runs out: return .run { [transcript = state.transcript] send in await send( .delegate(.saveMeeting(transcript: transcript)) ) await self.dismiss() }
— 2:17
And that right there is enough to actually save the transcript in the meeting when the meeting ends.
— 2:26
Unfortunately we have no way of seeing that right now since we don’t actually support drilling down to meetings yet. We just haven’t built that functionality yet.
— 2:44
But, even before getting to that we can see with our debug tool that the transcript is definitely being added to the meeting. Let’s run the app in the simulator again, record a quick meeting, and when the meeting finishes we see the following in the logs: meetings: [ + [0]: Meeting( + id: UUID(12ABD582-6910-45BE-BD78-25308168B969), + date: Date(2023-06-24T18:36:23.938Z), + transcript: """ + Test test 123 blob is speaking blah Esquire + """ + ), [1]: Meeting(…) ]
— 3:44
This proves that the transcript was captured in the meeting when it was added to the standups meeting array. It is absolutely incredible to see how much introspection we have into the deep, inner workings of our features.
— 4:00
Let’s quickly add a meeting drill down screen so that we can see our past meetings. This is actually incredibly easy because the meeting screen doesn’t have any behavior. It’s just an inert view showing some data.
— 4:19
Let’s create a new file, Meeting.swift, and paste in the following simple view for showing off the details of a meeting: import SwiftUI struct MeetingView: View { let meeting: Meeting let standup: Standup var body: some View { ScrollView { VStack(alignment: .leading) { Divider() .padding(.bottom) Text("Attendees") .font(.headline) ForEach(self.standup.attendees) { attendee in Text(attendee.name) } Text("Transcript") .font(.headline) .padding(.top) Text(self.meeting.transcript) } } .navigationTitle(Text(self.meeting.date, style: .date)) .padding() } }
— 4:31
Note that this view does not need the full power of a Composable Architecture feature. It doesn’t have any logic or behavior. It just needs a standup and meeting value, and then renders a basic view hierarchy. This shows that not every feature needs to be built with the Composable Architecture, especially leaf features. They can be plain, vanilla SwiftUI feature.
— 5:14
However, we can still integrate this simple feature into the rest of the Composable Architecture features. Since we want to show this view in the navigation stack, we will start by adding a case to the path enum for it: struct Path: Reducer { enum State: Equatable { … case meeting(Meeting, standup: Standup) … } … }
— 5:48
Note that we are holding the state in the enum case that the view needs access to. In particular, it needs the meeting we are displaying, as well as the standup the meeting is associated with.
— 6:00
Next we have the actions. However, the meeting view has no actions. It has no reducer because it has no logic or behavior. So, we could just leave the meeting case out of this Action enum entirely. Or, if we wanted to preserve the symmetric, we can add a case but hold Never inside the case: enum Action: Equatable { case detail(StandupDetail.Action) case meeting(Never) case recordMeeting(RecordMeeting.Action) }
— 6:18
This is effectively the same as having no case at all because the Never type cannot be instantiated, and so this case can never be created.
— 6:27
And then down in the view when switching on the path enum we can fill in the case for the meeting: case let .meeting(meeting, standup: standup): MeetingView(meeting: meeting, standup: standup)
— 6:57
That’s all it takes. Now this non-Composable Architecture feature is fully integrated into the rest of the application.
— 7:16
All we need to do now is create a navigation link for navigating to that view: NavigationLink( state: AppFeature.Path.State.meeting( meeting, standup: viewStore.standup ) ) { … }
— 8:01
And we now have a whole new screen we can drill down to. We can even start up a new meeting, record some bits of transcript, end the meeting, and then drill down in that meeting to see our transcript is there. Absolutely incredible.
— 8:37
And let’s run tests just to make sure they still pass. Well, we have one compilation error, and that’s because the saveMeeting delegate now takes a string. In this test we are using a “denied” speech recognizer and so there is no transcript: await store.receive( .path( .element( id: 1, action: .recordMeeting( .delegate(.saveMeeting(transcript: "")) ) ) ) )
— 9:31
Now tests compile, and when we run we get one small failure: A state change does not match expectation: … AppFeature.State( path: [ #0: .detail( StandupDetail.State( _destination: nil, standup: Standup( id: UUID( 49E317F7-C0C1-4313-9045-6403F857CA81 ), attendees: […], duration: 1 minute, meetings: [ [0]: Meeting( id: UUID( 00000000-0000-0000-0000-000000000000 ), date: Date(2009-02-13T23:31:30.000Z), − transcript: "N/A" + transcript: "" ) ], theme: .bubblegum, title: "Point-Free" ) ) ) ], standupsList: StandupsListFeature.State(…) ) (Expected: -, Actual: +)
— 9:49
And that’s just because previously we were hardcoding the transcript to be “N/A”. Now we are getting the transcript from the local state of the feature, and in this case the test is emulating the user not giving speech recognition permission, so the transcript state never changes.
— 9:57
Let’s fix that: $0.path[id: 0, case: /AppFeature.Path.State.detail]? .standup.meetings = [ Meeting( id: Meeting.ID(UUID(0)), date: Date(timeIntervalSince1970: 1234567890), transcript: "" ) ]
— 10:00
…and run tests again to see that now everything passes.
— 10:04
Let’s even write a quick test that the record flow works as we expect when speech recognition authorization is granted, and we will override the start endpoint to return a stream that immediately returns a string: func testTimerRunOutEndMeeting_WithSpeechRecognizer() async { let standup = Standup( id: UUID(), attendees: [Attendee(id: UUID())], duration: .seconds(1), meetings: [], theme: .bubblegum, title: "Point-Free" ) let store = TestStore( initialState: AppFeature.State( path: StackState([ .detail( StandupDetailFeature.State(standup: standup) ), .recordMeeting( RecordMeetingFeature.State(standup: standup) ), ]), standupsList: StandupsListFeature.State( standups: [standup] ) ) ) { AppFeature() } withDependencies: { $0.continuousClock = ImmediateClock() $0.date.now = Date(timeIntervalSince1970: 1234567890) $0.uuid = .incrementing $0.speechClient.requestAuthorization = { .authorized } $0.speechClient.start = { AsyncThrowingStream { $0.yield("This was a good meeting!") } } } store.exhaustivity = .off await store.send( .path(.element(id: 1, action: .recordMeeting(.onTask))) ) await store.skipReceivedActions() store.assert { XCTAssertEqual($0.path.count, 1) $0.path[id: 0, case: /AppFeature.Path.State.detail]? .standup.meetings = [ Meeting( id: Meeting.ID(UUID(0)), date: Date(timeIntervalSince1970: 1234567890), transcript: "This was a good meeting!" ) ] } }
— 11:55
This test passes!
— 11:57
The only way for the meeting that was inserted to have the string “This was a good meeting!” is if the reducer really does start a speech recognition task, subscribe to the async sequence, feed that data back into the system, and report it back to the parent with the delegate action. If any of those steps was performed incorrectly we would have an instant test failure.
— 12:21
For example, suppose we just forgot to start the speech recognition task: @Sendable private func onTask(send: Send<Action>) async { await withTaskGroup(of: Void.self) { group in // group.addTask { // guard // await self.speechClient.requestAuthorization() // == .authorized // else { return } // // do { // for try await transcript // in self.speechClient.start() { // await send(.speechResult(transcript)) // } // } catch {} // } … }
— 12:36
Then we of course get a test failure because it is not true that the dependency feeds data back into the system so that we get the transcript: Expected to receive the following action, but didn’t: … AppFeature.Action.path( .element( id: #1, action: .recordMeeting( .delegate( .saveMeeting( transcript: """ This was a really good meeting! """ ) ) ) ) )
— 12:50
So, while we cannot test the live speech recognizer APIs directly, we can at least see how our feature’s logic executes when the speech recognizer emits data. This is an incredibly important concept to grasp.
— 13:03
There are many people who would argue that we are not testing anything real about our features specifically because we have decided to mock the behavior of the speech recognizer. However, that is simply not true at all. Under the assumption that the speech recognizer is well-behaved, which isn’t a huge assumption considering that Apple maintains that API and we would hope it works as advertised, then we can truly assert how our feature code reacts to the speech client emitting various data.
— 13:32
And we can even write tests for the situations when the speech recognizer API is not well-behaved. For example, if we wanted to write a test of what happens when the recognizer emits a few transcripts and then suddenly errors, we could absolutely do that.
— 13:45
It is simply a matter of constructing an async throwing stream that yields a few transcripts and then suddenly throws an error: $0.speechClient.start = { AsyncThrowingStream { $0.yield("This") $0.yield("This was a") $0.yield("This was a really good") $0.yield("This was a really good meeting!") struct SomeError: Error, Equatable {} $0.finish(throwing: SomeError()) } }
— 14:20
And now we can write a test to make sure our feature behaves correctly when this unexpected error happens. Perhaps we want to show an alert to let the user know, and give them the option of either continuing with the meeting or abandoning it. Or maybe we try to fire up another speech task to see if it does better a second time around.
— 15:00
We aren’t going to implement any of that complex logic, but it is absolutely possible, and it would be 100% testable.
— 15:11
And before we end this section let’s show of yet another super power of the Composable Architecture. If we run the preview of the RecordMeetingFeature we will see that it’s mostly functional. We see the timer counting down, we can skip speakers, and we can even tap “End meeting” to see the alert. That’s really awesome, but also sometimes it can be really distracting.
— 16:17
Maybe we want to iterate on the styling of this screen and not constantly see the timer counting down. This would be really important if we had a really flashy feature with lots of things moving around. Sometimes we just want our feature to stay put and be a mostly inert representation of the state provided.
— 16:42
Well, the Composable Architecture makes this incredibly easy to do. Because the view is powered by a Store , and because we can stick in any kind of store we want as long as it works on the same state and actions of the view, we can choose to swap out our live store with something less… functional.
— 17:06
In fact, we can put in a store that doesn’t have a reducer at all, which means no logic or behavior will ever be executed: #Preview("Inert") { MainActor.assumeIsolated { NavigationStack { RecordMeetingView( store: Store( initialState: RecordMeeting.State(standup: .mock) ) { } ) } } }
— 17:22
When an action sent is sent to this store is simply does nothing. It does not mutate state and it does not execute side effects.
— 17:29
Now when we run this preview the timer is not counting down, and if we click on anything nothing happens.
— 17:45
And we can craft our state to be in a very specific configuration so that we can see exactly what the view looks like, like say after 9 seconds have elapsed: #Preview("Inert") { MainActor.assumeIsolated { NavigationStack { RecordMeetingView( store: Store( initialState: RecordMeeting.State( secondsElapsed: 9, standup: .mock ) ) { } ) } } }
— 17:58
This is incredibly powerful. You will often find that people in the iOS community advocate for a kind of “wrapper” or “container” view to achieve this. This is what you do when you have a complex view with lots of state and behavior: struct MyView: View { @State … @Binding … @ObservedObject … var body: some View { … } }
— 18:30
If you don’t structure your view correctly, such as controlling dependencies, it can often be the case that your preview breaks or isn’t able to display the exact data you want.
— 18:45
And so one will create a “container” view that holds onto all the state, and then another inner, “core” view that holds onto simply let values, and then pass along the data from the container to the core view: struct MyViewContainer: View { @State … @Binding … @ObservedObject … var body: some View { MyCoreView(…) } } struct MyCoreView: View { let … let … let … var body: some View { … } }
— 19:49
This is a lot of extra code just to make previews usable, and we think it’s just code that does not need to be written, ever. The pattern really starts to break down as your feature gets more complex, and any extra code you maintain is an opportunity for you to accidentally get something wrong or introduce a bug.
— 20:33
Well, in the Composable Architecture we don’t have to worry about any of that. We basically get this pattern for free. If you want to just see how your view looks with a specific configuration of state and not worry about actions or behavior at all, then you can stick in a store with no reducer. It’s that easy! Data persistence
— 21:06
And that is all it takes to implement the basics of the speech recognition logic. It is absolutely incredible to see how all of our hard work to set a good foundation for our app is starting to pay off. The Composable Architecture allows us to build our features with simple value types for the most part, and where we need effectful behavior it demarcates a special area for executing side effects in the outside world.
— 21:28
And best of all, its first-class dependency management makes it easy to supply the dependencies a feature needs to do its job, and keeps us in check when we accidentally use live dependencies in a testing context. Stephen
— 21:40
We are very close to re-implementing all of the Scrumdinger application using the Composable Architecture. All that is left is data persistence. We would like to make it so that whenever a new standup is added or changed in some way that we persist the data to disk, and then when the app first launches we load that data from disk to populate the initial standups.
— 22:00
Well, this is yet another one of those pesky side effects that can wreak havoc in a code base. But, thanks to the Composable Architecture, it is easy enough for us to take control of the file system rather than letting it control us.
— 22:12
Let’s take a look.
— 22:15
The standard APIs for reading from and writing to the file system is the write method on Data : Data().write(to: <#URL#>)
— 22:30
…and the init(contentsOf:) initializer: Data(contentsOf: <#URL#>)
— 22:35
Both take a URL that points to a local file on the device. We are going to want to use the same URL for both of these operations, and so let’s go ahead and define a single URL that can be easily accessed from anywhere: extension URL { static let standups = Self.documentsDirectory.appending(component: "standups.json") }
— 22:57
Now we just have to decide at what points do we want to save the data. One idea would be to tap into the moment the navigation stack pops back to the root: case .path(.popFrom(id: _)): if state.path.isEmpty { // TODO: Persist standups data }
— 23:32
That would certainly work for when we make edits in the detail screen, but it would not capture when a new standup is added. That logic is in the standups list feature, and so I guess we would have to listen for more actions to figure out the other times we want to persist the data.
— 23:46
However, rather than trying to guess all the times we need to save, why not be more proactive and just save the data once the root app reducer has finished processing any action. We can do this by simply mixing in another Reduce reducer at the end of the app reducer’s body : var body: some ReducerOf<Self> { … Reduce { state, _ in .run { _ in // TODO: Persist standups data } } }
— 24:23
We now have the ability to execute an effect after the app reducer has processed any action. That means we will always get the freshest standup data after any action is dealt with.
— 24:32
Now of course it isn’t going to be the most efficient thing to literally save the data after every single action is sent into the system. We really only need to persist data every once in a while. What if we could debounce the effect so that it executes at most once every second. That way if a bunch of actions come in rapidly, we will not thrash the file system with a bunch of superfluous saves.
— 0:00
Luckily this is quite easy. We can demarcate a little area of an effect to be cancellable via an ID, and further make it so that if another task is started with that ID the previous task will be cancelled: Reduce { state, _ in .run { _ in enum CancelID { case saveDebounce } try await withTaskCancellation( id: CancelID.saveDebounce, cancelInFlight: true ) { } } }
— 0:00
Then if we perform a sleep in this task and perform some work after the sleep we would have effectively debounced the effect: Reduce { state, _ in .run { _ in enum CancelID { case saveDebounce } try await withTaskCancellation( id: CancelID.saveDebounce, cancelInFlight: true ) { try await Task.sleep(for: .seconds(1)) // TODO: Persist debounced standups data } } }
— 26:08
Now of course we aren’t going to want to use a live Task.sleep because we’ve seen in forces us to wait for real world time to pass in tests, so let’s go ahead and inject a clock into our feature: @Dependency(\.continuousClock) var clock
— 26:27
And let’s use the clock instead of using the live Task.sleep : Reduce { state, _ in .run { _ in enum CancelID { case saveDebounce } try await withTaskCancellation( id: CancelID.saveDebounce, cancelInFlight: true ) { try await self.clock.sleep(for: .seconds(1)) // TODO: Persist debounced standups data } } }
— 26:32
Now what work do we want to perform in here? We want to save the current standups array to the URL we hard coded above. This can be done by encoding the array to JSON data, and then writing that data to disk: Reduce { state, _ in .run { [standups = state.standupsList.standups] _ in enum CancelID { case saveDebounce } try await withTaskCancellation( id: CancelID.saveDebounce, cancelInFlight: true ) { try await self.clock.sleep(for: .seconds(1)) try JSONEncoder().encode(standups).write( to: .standups ) } } }
— 27:12
That’s all it takes.
— 27:15
Of course, we have introduced an uncontrolled dependency into our feature code, the write method on Data . That is going to cause a lot of problems, but let’s wait until we see those problems to fix them.
— 27:28
So, we are now periodically saving the standups data when an action is sent into the system. It is kind of incredible to see how easy this is. We get one single place to insert this little bit of logic. We don’t have to sprinkle data persisting logic throughout our application like you would typically do in a core data app or vanilla SwiftUI app.
— 27:50
And this shows the power of having all feature mutations occur only in reducers and not allowing direct mutations outside of reducers. If we were allowed to mutate the state in a store directly, then we couldn’t have this single hook for persisting data and be confident that we will always save the freshest data when it changes.
— 28:08
There’s one other side to data persistence, and that’s loading the data when the app first launches. There are a few ways one can do this. You could of course add a appLaunched action to the root level reducer, which will be sent when the app first launches. That would be a natural place to populate the standups list with the data from the file system.
— 28:25
But, there’s a shortcut we can take that makes the initial loading of data a little more seamless in our opinion. It may seem weird at first, but we think it’s completely OK to do and does not hinder any of the pillars of the Composable Architecture that we value, such as deep linkability and testability.
— 28:39
What we are going to do is load the data right in the initializer of the StandupListFeature ’s state. That is, we are going to provide a custom initializer that no longer allows you to pass along an array of standups directly, but instead retrieves its initial standups directly from the file system: struct StandupsListFeature: Reducer { struct State: Equatable { @PresentationState var addStandup: StandupFormFeature.State? var standups: IdentifiedArrayOf<Standup> = [] init( addStandup: StandupFormFeature.State? = nil ) { self.addStandup = addStandup do { self.standups = try JSONDecoder().decode( IdentifiedArrayOf<Standup>.self, from: Data(contentsOf: .standups) ) } catch { self.standups = [] } } } … }
— 29:31
This of course seems like a dangerous thing to do. We are accessing this huge, scary effectful thing, the file system, right in the initializer of what should be a simple thing, a struct. However, we hope to convince you that this can be a reasonable approach.
— 29:45
But, before getting to that, let’s see that all the work we have done so far behaves correctly. First to get things compiling again we have to update a bunch of previews and the entry point of the app to no longer provide an initial state with explicit standups. That simply is not allowed anymore. So, that seems bad. We can no longer preview and run our app in a very specific state, but we will see how to recover it in a moment.
— 30:30
Now when we run the app in the simulator we will see that there are no standups loaded, and that’s because there are no previously saved standups in the file system. We can add a standup, and then quite the app and relaunch to see that now a single standup is pre-loaded. And we can drill down to the standup, make an edit, force quit again, relaunch and see that the edited standup is preloaded. And we can even record a meeting, quit the app, re-open, and see the meeting was persisted.
— 31:21
So, it does work, but the manner in which we’ve implemented persistence is not ideal. First of all, as we saw a moment ago, we no longer have a way to load our features in any state we want. We are at the mercy of the file system to provide us with standups. So, if we wanted to run our app in a state with hundreds of standups pre-populated, we would have to literally add them in the simulator, or write to the filesystem directly with some debug code, which is quite messy.
— 31:44
Further, now we will see that our previews even persist data. If I run the app preview, add a standup, and then refresh the preview, the standup is still populated. That is going to hinder our ability to test all the subtle edge cases of our application. If I want to get back to an empty state at the root I need to go into that standup and delete it.
— 32:15
Overall we have just lost a ton of flexibility when it comes to controlling the exact state and environment our application runs in.
— 32:21
And if all of that wasn’t bad enough, it is of course going to be very difficult to write a passing test since the file system is a global dependency that will bleed over from test to test. I’m going to paste in a very simple non-exhaustive, integration test that simply wants to prove that when we go through the full add flow the standups count goes to 1: func testAdd() async { let store = TestStore( initialState: AppFeature.State( standupsList: StandupsListFeature.State() ) ) { AppFeature() } store.exhaustivity = .off await store.send(.standupsList(.addButtonTapped)) await store.send(.standupsList(.saveStandupButtonTapped)) store.assert { $0.standupsList.standups = [ Standup( id: UUID(0), attendees: [Attendee(id: UUID(1))] ) ] } }
— 32:49
We of course would hope this passes, but before we can run it we have a few tests to fix. Some are not compiling because we are providing explicit arrays of standups, which is no longer allowed. We will comment out those parts of the initializers. Of course all of those tests will probably fail now, but let’s ignore that for now.
— 33:12
If we run our new test we will see it fails: A state change does not match expectation: … AppFeature.State( path: [:], standupsList: StandupsListFeature.State( _addStandup: nil, standups: [ + [0]: Standup( + id: UUID( + 0DBF94F3-DFB8-4416-9CE7-5678447DE6C8 + ), + attendees: [ + [0]: Attendee( + id: UUID( + AE0BD168-E6AE-4F08-94D8-4309D6F7C7C0 + ), + name: "" + ) + ], + duration: 5 minutes, + meetings: [], + theme: .bubblegum, + title: "" + ), [1]: Standup(…) ] ) ) (Expected: −, Actual: +)
— 33:20
Somehow the standups array has 2 elements instead of 1. Is that a bug in our code? Are we accidentally adding two standups during the add flow?
— 33:32
Well, no actually. This is happening because our use of the simulator a moment is bleeding over into tests. We have a single standup in the simulator, and this test is loading that data when the test runs. Data manager dependency
— 33:48
All of this is going to show that again we have an uncontrolled dependency in our feature, and it is wreaking havoc on our ability to run our features in whatever manner we want and our ability to write deterministically passing tests.
— 34:00
Brandon : So, let’s take back control of this dependency rather than letting it control us.
— 34:34
We are going to design a very simple dependency interface that allows us to load and save data to disk via a URL: import Foundation struct DataManager: Sendable { var load: @Sendable (URL) throws -> Data var save: @Sendable (Data, URL) throws -> Void }
— 35:27
With the interface defined we can conform it to the DependencyKey protocol as a first step to registering it with the dependency system: import Dependencies extension DataManager: DependencyKey { static let liveValue = Self( load: { url in try Data(contentsOf: url) }, save: { data, url in try data.write(to: url) } ) } Here we have provided a liveValue that is allowed to actually reach out to foundation’s Data APIs because this value is used in the simulator and on devices.
— 36:25
We can also provide some additional, useful conformances to the DataManager , such as one that simply fails if you try to save with it: static let failToWrite = Self( load: { _ in Data() }, save: { _, _ in struct SaveError: Error {} throw SaveError() } )
— 37:00
Or one that fails if you ever try to load with it: static let failToLoad = Self( load: { _ in struct LoadError: Error {} throw LoadError() }, save: { _, _ in } )
— 37:15
Such conformances can be useful in tests and previews for exercising the unhappy paths of your feature’s logic. For example, we should probably be showing an alert if saving or loading data failed, but we won’t worry about any of that now.
— 38:19
Another fun implementation of the dependency is one that fakes a file system without ever touching the global, shared, mutable file system: static func mock(initialData: Data? = nil) -> Self { let data = LockIsolated(initialData) return Self( load: { _ in guard let data = data.value else { struct FileNotFound: Error {} throw FileNotFound() } return data }, save: { newData, _ in data.setValue(newData) } ) }
— 39:01
This allows you to start the data manager in a state where it pretends it has some data already saved to the quote-un-quote “file system”, so when you “load” the data you get that data back, and when you “save” the data we just overwrite it.
— 39:13
We could of course beef up this mock data manager significantly more by holding onto a dictionary that maps URLs to data blobs in order to emulate a full file system, but since currently we only have a single URL we are dealing with we don’t feel that is necessary. But we highly encourage you to exploring making a more full featured mock data manager dependency so that this better reflects reality in tests.
— 39:35
And now that we have these useful other conformances of the data manager interface, we can set up a previewValue for the dependency so that we don’t access the real life filesystem in previews. In particular, we will just use a mock data manager: static let previewValue = Self.mock()
— 39:57
This means previews will use a “fake” file system, which fixes the problem we noticed a moment ago. Each time we run the preview we will not be loading data from the previous times the preview was run, and instead will start with a clean slate.
— 40:11
OK, now that we have a wide variety of implementations of the data manager dependency we can completely the 2nd and final step of registering it with the dependency system: extension DependencyValues { var dataManager: DataManager { get { self[DataManager.self] } set { self[DataManager.self] = newValue } } }
— 40:36
With that done we are free to this dependency in any feature.
— 40:47
For example, in the AppFeature we can declare our dependence on the data manager: @Dependency(\.dataManager) var dataManager
— 41:04
But even cooler, we can further scope down the dependency to just the bare essentials we actually need for the app feature. In particular, the only thing the app feature can do is save data. We never actually load data.
— 41:17
So, why not make that clear as day by declaring our dependency only on the save endpoint: @Dependency(\.dataManager.save) var saveData
— 41:21
This can be incredibly useful for trimming down dependencies so that it is clear to your colleagues that your feature really only needs a small bit of a particular dependency.
— 41:38
It’s also worth noting that this style of slimming dependencies down is only possible thanks to our decision to model dependency interfaces as structs with mutable closure fields. This is not possible to do when we simply put a protocol in front of our dependencies.
— 41:57
Now down in the reducer we can use this saveData endpoint rather than use the live, uncontrolled write API: try await saveData( JSONEncoder().encode(standups), .standups )
— 42:11
Everything should work exactly as it did before, but we are now using a dependency that we can control from the outside.
— 42:23
We can do something similar in the StandupsListFeature.State initializer: @Dependency(\.dataManager.load) var loadData self.standups = try JSONDecoder().decode( IdentifiedArrayOf<Standup>.self, from: loadData(.standups) )
— 42:58
And just like that we have fixed all of the problems we mentioned a moment ago.
— 43:03
First of all, we can now run the app preview multiple times and see that data does not persist across runs. We can add a standup, refresh the preview, and see that the list goes back to empty.
— 43:27
However, if we do really want to load the preview in a state where some standups are already present in the fake file system, then all we have to do is override the dataManager dependency to be a mock client that starts with some initial data. This would be handy in the second preview where we want to start in a very specific state of being drilled down to the detail screen and record screen, and in order to do so we should already have a standup in the state: var standup = Standup.mock let _ = standup.duration = .seconds(6) AppView( store: Store( initialState: AppFeature.State( path: StackState([ .detail( StandupDetailFeature.State(standup: standup) ), .recordMeeting( RecordMeetingFeature.State(standup: standup) ) ]) ) ) { AppFeature() } withDependencies: { $0.dataManager = .mock( initialData: try? JSONEncoder().encode([standup]) ) } ) .previewDisplayName("Quick finish meeting")
— 44:15
That’s all it takes and now this preview works exactly as it did before, but we are now loading data from disk using a dependency that can be fully controlled.
— 44:30
This fixes all of the problems we were having in our previews, but it also fixes the problems we were having in our tests. Let’s run the testAdd test we created just a moment ago to see that we get a new kinds of failures: testAdd(): @Dependency(\.dataManager) has no test implementation, but was accessed from a test context: Previously the failure was that we had 2 standups when we only expected 1, and that is because the things we did over in the simulator were bleeding over into tests.
— 44:45
Now we are getting failures letting us know that we are using a live dependency when we should not be. It is specifically telling us that using live dependencies is a bad thing because it can lead to the exact problems we were witnessing a moment ago.
— 44:57
So, let’s override each of these dependencies: let store = TestStore( initialState: AppFeature.State( standupsList: StandupsListFeature.State() ) ) { AppFeature() } withDependencies: { $0.continuousClock = ImmediateClock() $0.dataManager = .mock() $0.uuid = .incrementing }
— 45:08
And almost as if by magic the test passes. And we can run it many times and it still passes.
— 45:23
It is no longer true that one test can bleed over into another test. Each test gets its own little sandboxed filesystem to muck around in without worrying about affecting any other test. And that’s pretty incredible.
— 45:43
We also have a few tests we need to update since standups are now being loaded from disk. Instead of providing standups directly in state, we need to make sure to override the dataManager dependency to provide the initial standups, and we need to override the continuousClock dependency since it will begin to debounce the work of saving dependencies. $0.continuousClock = ImmediateClock() $0.dataManager = .mock(initialData: …)
— 47:49
And now the entire test suite is passing, and everything works exactly as it did before, but we have made working with the code base a lot more enjoyable. We have data persistence implemented and we don’t have to worry about messing up our previews or tests. It’s really fantastic.
— 48:16
And it’s worth comparing our tests to how one would test Apple’s Scrumdinger application. Well, first of all, the Scrumdinger app doesn’t have any tests, and the vast majority of it cannot be unit tested due to the use of uncontrolled dependencies and @State throughout the views.
— 49:26
However, one tool that can be used to try to test basically any app out there no matter how it’s built is UI tests. Such tests allow you to simulate literal actions the user does in the UI and you can try to assert on what is displayed in the UI after those actions.
— 49:53
Let’s add a UI test target to Scrumdinger.
— 50:01
And I’m going to paste in a basic test: import XCTest @MainActor final class ScrumdingerUITests: XCTestCase { let app = XCUIApplication() override func setUpWithError() throws { self.continueAfterFailure = false } func testBasics() async throws { self.app.launch() self.app.buttons["New Scrum"].tap() self.app.textFields["Title"].tap() self.app.typeText("Engineering") self.app.textFields["New Attendee"].tap() self.app.typeText("Blob") self.app.buttons["Add attendee"].tap() self.app.textFields["New Attendee"].tap() self.app.typeText("Blob Jr.") self.app.buttons["Add attendee"].tap() self.app.buttons["Add"].tap() XCTAssertEqual(self.app.cells.count, 1) XCTAssertEqual( self.app.staticTexts["Engineering"].exists, true ) XCUIDevice.shared.press(.home) self.app.launch() XCTAssertEqual( self.app.staticTexts["Engineering"].exists, true ) } }
— 50:11
This test exercises the flow of adding a daily scrum, verifying that there’s a row in the root list with that scrum. Then it closes the app, re-launches it from scratch, and verifies that the scrum is still in the root list. This proves that not only does the “Add scrum” feature communicate with the root feature to add the scrum to the collection, but that even persistence works. Because if persistence did not work, then when we re-launch the app the “Engineering” meeting would not be in the list.
— 51:03
Let’s run it real quick to make sure it passes…
— 51:26
Well it doesn’t! It took quite a bit longer than our unit tests: XCTAssertEqual failed: (“2”) is not equal to (“1”)
— 51:28
It is not true that there was only 1 row in the root list. There were 2 rows. While we ran that test, we had a daily scrum we added earlier already sitting in the list, which bled over into the test.
— 52:02
We could delete the app and run the test from scratch…
— 52:26
And it passes!
— 52:28
So that seems kinda great. The Scrumdinger app was built without caring about any of the things we’ve harped on for the last 5 episodes yet it seems it is perfectly testable using UI tests.
— 52:47
Well, let’s run it again just to be sure…
— 52:55
But of course it fails: XCTAssertEqual failed: (“2”) is not equal to (“1”)
— 53:02
This is happening because of the uncontrolled dependency on the file system. The changes made to the file system in one test is bleeding over into other tests. We have no choice but to weak our assertion. We can’t assert that there is exactly 1 meeting in the root list, but that there’s at least 1 meeting: XCTAssert(self.app.cells.count >= 1)
— 53:24
So if we ever introduced a bug that accidentally added two scrums this test would continue passing and we would be blissfully unaware.
— 53:32
But worse, data from an earlier test run can cause a test to pass even if we introduce an error to our test code, and we won’t know till we delete the app and run things from scratch.
— 53:42
This shows plain as day why we need to control our dependencies, and why we often cannot lean on UI testing alone to get test coverage on our features.
— 53:58
And all this goes without mentioning that the UI tests are incredibly slow. It took over 13 seconds to run this single test, whereas the entire standups suite, which exercises much of the app’s logic, runs in less than half a second…and that half a second is also longer than it needs to be. We have a stub performance test still present from our generated project code. If we get rid of it, the entire suite is done in just over a tenth of a second: Executed 11 tests, with 0 failures (0 unexpected) in 0.163 (0.167) seconds.
— 54:50
That is over 80 times faster than the single UI test. Outro
— 54:55
OK, that concludes our tour of the Composable Architecture 1.0. Stephen
— 55:01
We have covered a ton in these past 7 episodes, including domain modeling, navigation, side effects, effect cancellation, testing, dependency management, and more. This will give you enough knowledge to be dangerous with the library, but there is still a lot out there to learn to truly master it. We highly recommend you check out all of the case studies and demo applications included in the library to see more advanced usage of the library. Brandon
— 55:28
And even though we have officially released 1.0 of the library there is still a lot left to be done. Soon we will be exploring ways to integrate the new Observable protocol into the Composable Architecture, as well as figuring out how macros can make building features even simpler and more ergonomic. These tools are going to massively simplify what it takes to build features with the library, but all of that will have to wait for another time.
— 55:51
Until next time… References Composable Architecture Brandon Williams & Stephen Celis • May 4, 2020 The Composable Architecture is a library for building applications in a consistent and understandable way, with composition, testing and ergonomics in mind. http://github.com/pointfreeco/swift-composable-architecture Getting started with Scrumdinger Apple Learn the essentials of iOS app development by building a fully functional app using SwiftUI. https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger Downloads Sample code 0249-tca-tour-pt7 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .