Uploaded by Star Bucks

mobile system design v007

advertisement
MOBILE
SYSTEM
DESIGN
RESOURCEFUL ENGINEERING
Become a better app developer using
mental models that apply to the real world
TJEERD IN ’T VEEN
Contents
Preface
0.1 Acknowledgements . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
0.2 This book is an early release edition; What to expect . . . . . . . . . . . . . . . . . . .
1
3
4
1 About this book
1.1 System Design versus Software Architecture . . . . . . . . . . . . . . . . . . . . . . .
1.2 Why is System Design important? . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.3 Common challenges for mobile engineers . . . . . . . . . . . . . . . . . . . . . . . .
1.3.1 Rapidly changing environment . . . . . . . . . . . . . . . . . . . . . . . . . .
1.4 Why does this book exist? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.5 Why this book’s subtitle is called ’Resourceful Engineering’ . . . . . . . . . . . . . . .
1.6 What to expect during Mobile System Design interviews . . . . . . . . . . . . . . . . .
1.7 What this book is not . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.7.1 This book is not a traditional programming book . . . . . . . . . . . . . . . .
1.8 This book is about timeless principles, not trends . . . . . . . . . . . . . . . . . . . .
1.9 This book is for iOS, Android, and multi-platform developers alike . . . . . . . . . . .
1.10 Is this book for you? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.11 How this book works . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.11.1 A strong focus on building the right things . . . . . . . . . . . . . . . . . . . .
1.12 The chapters . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.12.1 Chapter 1: About this book . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.12.2 Chapter 2: Turning a briefing into a strong plan . . . . . . . . . . . . . . . . .
1.12.3 Chapter 3: Holistic-Driven Development; Turning a plan into code . . . . . . .
1.12.4 Chapter 4: System-wide testing; Delivering higher quality apps . . . . . . . . .
1.12.5 Chapter 5: Cross-domain testing; Testing more with less effort . . . . . . . . .
1.12.6 Chapter 6: Dependency injection foundations . . . . . . . . . . . . . . . . . .
1.12.7 Chapter 7: Sane dependency injection without fancy frameworks . . . . . . .
1.12.8 Chapter 8: Dependency Injection on a larger scale . . . . . . . . . . . . . . . .
1.12.9 Chapter 9: UI frameworks, architectures, and supporting multiple products . .
1.12.10 Chapter 10: Delivering reusable UI views; The art of decomposing a design . .
1.12.11 Chapter 11: Reasoning about Views, Components, Screens, and Bindings . . .
5
6
6
7
8
8
9
10
11
12
13
13
14
14
14
15
15
15
15
16
16
16
17
17
17
18
18
i
Mobile System Design
1.12.12 Chapter 12: Pragmatically implementing UI . . . . . . . . . . . . . . . . . . .
18
1.12.13 Chapter 13: Delivering self-sufficient features, part I: The art of staying nimble
19
1.12.14 Chapter 14: Delivering self-sufficient features, part II; Self-loading features . .
19
1.12.15 Chapter 15: Delivering self-sufficient features, part III; Making features portable 20
1.12.16 Upcoming chapters . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
21
1.13 About the author . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
22
2 Turning a briefing into a strong plan
2.1
The briefing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
24
2.1.1
An initial impression . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
26
Evaluating common approaches . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
26
2.2.1
Start with UI? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
27
2.2.2
A data-focused approach? . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
27
2.2.3
Creating an app-skeleton or flow-skeleton? . . . . . . . . . . . . . . . . . . .
28
2.2.4
Starting by making components or features? . . . . . . . . . . . . . . . . . . .
28
2.2.5
Drawing a diagram? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
28
2.2.6
Decide on an architecture? . . . . . . . . . . . . . . . . . . . . . . . . . . . .
29
2.2.7
A recommended approach . . . . . . . . . . . . . . . . . . . . . . . . . . . .
30
Sketching out a landscape . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
30
2.3.1
Everything is connected to a course . . . . . . . . . . . . . . . . . . . . . . . .
32
2.3.2
How far do we decompose? . . . . . . . . . . . . . . . . . . . . . . . . . . . .
33
2.4
Uncovering secondary requirements . . . . . . . . . . . . . . . . . . . . . . . . . . .
34
2.5
Working with Designers; Getting secondary features . . . . . . . . . . . . . . . . . . .
35
2.5.1
Whether or not a design is the "law" . . . . . . . . . . . . . . . . . . . . . . .
36
2.5.2
What is “pixel perfect”, really? . . . . . . . . . . . . . . . . . . . . . . . . . . .
36
2.5.3
Designs often encompass best-case scenarios . . . . . . . . . . . . . . . . . .
37
2.5.4
Not everything has equal priority . . . . . . . . . . . . . . . . . . . . . . . . .
37
2.5.5
Verify the existence of pre-existing components . . . . . . . . . . . . . . . . .
38
2.5.6
Ask general UI questions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
39
2.5.7
Ask functionality-related questions . . . . . . . . . . . . . . . . . . . . . . . .
40
2.5.8
Talk about error handling . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
41
2.5.9
Talk about time-investments and start thinking in a less binary fashion . . . .
41
2.5.10 Giving feedback to the designer . . . . . . . . . . . . . . . . . . . . . . . . . .
42
2.5.11 Updating the landscape . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
42
2.5.12 A fast app is key . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
43
2.5.13 Scheduler . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
43
2.5.14 Deep Linking . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
44
2.2
2.3
ii
23
Mobile System Design
2.6
Aligning with backend engineers . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
44
2.6.1
Align on about User sessions, environments, tokens, and timeouts . . . . . . .
45
2.6.2
Align on consolidating network calls . . . . . . . . . . . . . . . . . . . . . . .
45
2.6.3
Be on the same page with errors . . . . . . . . . . . . . . . . . . . . . . . . .
46
2.6.4
It’s okay to deviate from backend custom error codes . . . . . . . . . . . . . .
46
2.6.5
You might be the backend guinea-pig . . . . . . . . . . . . . . . . . . . . . . .
47
2.6.6
Read code from other client implementations . . . . . . . . . . . . . . . . . .
47
2.6.7
Consider push notifications . . . . . . . . . . . . . . . . . . . . . . . . . . . .
48
2.6.8
Feature-specific questions . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
48
2.6.9
Updating the landscape with backend requirements . . . . . . . . . . . . . .
49
2.7
You are the link between backend and design . . . . . . . . . . . . . . . . . . . . . .
49
2.8
Closing thoughts . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
50
2.9
The takeaways . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
50
3 Holistic-Driven Development; Turning a plan into code
53
3.1
Be able to handle unknowns . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
54
3.2
A quick note about interfaces and APIs . . . . . . . . . . . . . . . . . . . . . . . . . .
54
3.3
The relationship between graph nodes and code . . . . . . . . . . . . . . . . . . . . .
55
3.4
The process of holistic driven development . . . . . . . . . . . . . . . . . . . . . . . .
56
3.5
Implementing the Course domain . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
58
3.5.1
Course and Tutor . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
60
3.5.2
TODOItem . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
61
3.5.3
Placeholder values . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
62
3.5.4
Modeling CalendarEvent . . . . . . . . . . . . . . . . . . . . . . . . . . .
63
3.5.5
Defining CourseAPI . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
64
The Store component . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
66
3.6.1
Starting from the call-site of CourseAPI . . . . . . . . . . . . . . . . . . . . . .
66
3.6.2
The Store implementation . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
68
3.6.3
Designing the get() method signature . . . . . . . . . . . . . . . . . . . . . . .
68
3.6.4
Implementing the get() method . . . . . . . . . . . . . . . . . . . . . . . . . .
70
3.6.5
Store is implemented naïvely on purpose . . . . . . . . . . . . . . . . . . . .
71
3.6.6
Placeholder implementations lower priorities . . . . . . . . . . . . . . . . . .
71
3.6.7
Trade-offs when making a component reusable . . . . . . . . . . . . . . . . .
71
3.7
Performing a little testrun and moving forward . . . . . . . . . . . . . . . . . . . . . .
72
3.8
Focusing on UI vs other implementations . . . . . . . . . . . . . . . . . . . . . . . . .
74
3.8.1
Focusing on UI . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
74
3.8.2
Going deeper . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
75
3.8.3
A team setting . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
76
3.6
iii
Mobile System Design
3.9
Implementing on a deeper level . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
77
3.9.1
Defining new types . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
79
3.9.2
Optimizing CourseAPI . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
80
3.10 End result . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
81
3.11 Reflecting on holistic driven development . . . . . . . . . . . . . . . . . . . . . . . .
81
3.11.1 Holistic-Driven Development brings confidence to move forward . . . . . . . .
82
3.11.2 Lightweight restructuring . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
82
3.11.3 Context switching and delegation . . . . . . . . . . . . . . . . . . . . . . . . .
83
3.11.4 Top-down versus bottom-up . . . . . . . . . . . . . . . . . . . . . . . . . . .
83
3.11.5 We delay writing tests . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
84
3.11.6 Why we don’t design with interfaces or protocols instead . . . . . . . . . . . .
84
3.11.7 A note on placeholders . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
85
3.12 The takeaways . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
86
4 System-wide testing; Delivering higher quality apps
4.1
Testing less granularly . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
87
4.2
Damage control and damage prevention . . . . . . . . . . . . . . . . . . . . . . . . .
88
4.2.1
Damage control . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
89
Mocking higher in the stack . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
90
4.3.1
Isolating VideoClient . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
91
4.3.2
Introducing mocks . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
92
4.3.3
Setting up a testing environment . . . . . . . . . . . . . . . . . . . . . . . . .
93
4.3.4
Downsides of mocking higher in the stack . . . . . . . . . . . . . . . . . . . .
94
Mocking lower in the stack . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
95
4.4.1
Finding the smallest surface to mock out . . . . . . . . . . . . . . . . . . . . .
96
4.4.2
Setting up the API dependency . . . . . . . . . . . . . . . . . . . . . . . . . .
97
4.4.3
Trade-offs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
98
4.4.4
Mocking for expensive operations . . . . . . . . . . . . . . . . . . . . . . . . .
99
4.4.5
Accepting a closure as a dependency . . . . . . . . . . . . . . . . . . . . . . . 101
4.4.6
The end result . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
4.3
4.4
4.5
iv
87
Making system-wide testing smoother . . . . . . . . . . . . . . . . . . . . . . . . . . 102
4.5.1
Dealing with boilerplate . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 103
4.5.2
Reducing boilerplate in production code . . . . . . . . . . . . . . . . . . . . . 103
4.5.3
Reducing boilerplate in tests . . . . . . . . . . . . . . . . . . . . . . . . . . . 105
4.5.4
Dealing with slower-running tests . . . . . . . . . . . . . . . . . . . . . . . . . 106
4.5.5
Consider using file systems in your tests . . . . . . . . . . . . . . . . . . . . . 107
4.5.6
Dealing with untestable code . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
4.5.7
Distance between tests and code . . . . . . . . . . . . . . . . . . . . . . . . . 108
Mobile System Design
4.6
What is a unit anyway? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
4.7
What we covered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
5 Cross-domain testing; Testing more with less effort
111
5.1
Avoiding a redundant testing surface . . . . . . . . . . . . . . . . . . . . . . . . . . . 112
5.2
The most important domain lives up top . . . . . . . . . . . . . . . . . . . . . . . . . 113
5.3
Be aware of volatile code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
5.4
Reason about classes the same way as you do with domains . . . . . . . . . . . . . . 115
5.5
Test the foundational domains as a next priority . . . . . . . . . . . . . . . . . . . . . 115
5.6
Trade-offs when testing lower domains later . . . . . . . . . . . . . . . . . . . . . . . 116
5.7
Domains in the context of a larger app . . . . . . . . . . . . . . . . . . . . . . . . . . 116
5.8
5.7.1
When we are working in the "highest" domain . . . . . . . . . . . . . . . . . . 118
5.7.2
Domains are only responsible for their own functionality . . . . . . . . . . . . 119
What we covered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 120
6 Dependency injection foundations
6.1
121
Vanilla code versus third-party frameworks . . . . . . . . . . . . . . . . . . . . . . . . 122
6.1.1
The cost of third-party solutions . . . . . . . . . . . . . . . . . . . . . . . . . 123
6.2
Why we need Dependency Injection in the first place . . . . . . . . . . . . . . . . . . . 123
6.3
Testing and mocking . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125
6.3.1
Dependency injection, testing, and interfaces . . . . . . . . . . . . . . . . . . 126
6.3.2
The purpose of an interface isn’t instantly clear . . . . . . . . . . . . . . . . . 128
6.4
Compiler-flags and environments . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 128
6.5
Singletons, or "What if there’s only one instance of something?" . . . . . . . . . . . . 129
6.5.1
Setting up a singleton . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 130
6.5.2
Singletons are often abused as shortcuts . . . . . . . . . . . . . . . . . . . . . 132
6.5.3
Solving problems for the future . . . . . . . . . . . . . . . . . . . . . . . . . . 132
6.5.4
Singletons hinder modularization . . . . . . . . . . . . . . . . . . . . . . . . . 133
6.5.5
Passing values across modules instead . . . . . . . . . . . . . . . . . . . . . . 134
6.5.6
Singletons, thread-safety, and global state . . . . . . . . . . . . . . . . . . . . 136
6.5.7
A thread-safe singleton is not enough . . . . . . . . . . . . . . . . . . . . . . . 137
6.5.8
Removing a singleton dependency . . . . . . . . . . . . . . . . . . . . . . . . 137
6.5.9
Use-cases for singletons . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139
6.5.10 Passing values pays off . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139
6.6
What we covered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 140
7 Sane dependency injection without fancy frameworks
7.1
141
A naïve solution . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 142
v
Mobile System Design
7.2
Deeply nested dependencies; The ABC problem . . . . . . . . . . . . . . . . . . . . . 143
7.2.1
Flipping the hierarchy inside out . . . . . . . . . . . . . . . . . . . . . . . . . 144
7.2.2
Setting up the environment . . . . . . . . . . . . . . . . . . . . . . . . . . . . 145
7.2.3
Updating CourseAPI . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 146
7.3
Compiler-flags on the outer edge of your application . . . . . . . . . . . . . . . . . . 147
7.4
The secret sauce . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
7.4.1
7.5
7.6
7.7
Breaking the ABC rule . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 149
Growing the app . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 150
7.5.1
Extending the graph with more classes . . . . . . . . . . . . . . . . . . . . . . 151
7.5.2
Flipping the graph . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 152
7.5.3
A larger ABC problem in code . . . . . . . . . . . . . . . . . . . . . . . . . . . 153
When dependencies aren’t available . . . . . . . . . . . . . . . . . . . . . . . . . . . 154
7.6.1
A payment flow . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 154
7.6.2
Optional dependencies . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 156
Lazy dependencies . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 157
7.7.1
Expressing a factory in code . . . . . . . . . . . . . . . . . . . . . . . . . . . . 157
7.7.2
Using the factory . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 159
7.8
Closing thoughts . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 162
7.9
What we covered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163
8 Dependency Injection on a larger scale
8.1
Considering a common approach . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 166
8.2
Handling larger dependency trees . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167
8.3
8.4
vi
165
8.2.1
Reducing a sub-tree . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167
8.2.2
Settings as a setup location . . . . . . . . . . . . . . . . . . . . . . . . . . . . 169
8.2.3
Expressing the dependencies in code . . . . . . . . . . . . . . . . . . . . . . . 170
8.2.4
Grouping the setup methods . . . . . . . . . . . . . . . . . . . . . . . . . . . 173
Dependencies across an entire app . . . . . . . . . . . . . . . . . . . . . . . . . . . . 174
8.3.1
Multiple setup locations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 174
8.3.2
Downsides of our approach . . . . . . . . . . . . . . . . . . . . . . . . . . . . 175
Passing dependencies across a modular app . . . . . . . . . . . . . . . . . . . . . . . 176
8.4.1
A modular app . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 177
8.4.2
A spider in the web . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 178
8.4.3
Reducing tight-coupling between modules . . . . . . . . . . . . . . . . . . . . 179
8.4.4
Expressing a solution in code . . . . . . . . . . . . . . . . . . . . . . . . . . . 180
8.4.5
The ABC problem appears again . . . . . . . . . . . . . . . . . . . . . . . . . 181
8.4.6
Solving the ABC problem across module bounds . . . . . . . . . . . . . . . . 182
8.4.7
A cleaner graph . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 184
Mobile System Design
8.4.8
8.5
Closing thoughts . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 188
8.5.1
8.6
Reducing tight-coupling for foundational modules . . . . . . . . . . . . . . . 185
Trade-offs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 188
What we covered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 190
9 UI frameworks, architectures, and supporting multiple products
193
9.1
UI Principle 1: Defer implementing the UI . . . . . . . . . . . . . . . . . . . . . . . . . 194
9.2
UI Principle 2: UI architectures come and go . . . . . . . . . . . . . . . . . . . . . . . 195
9.3
9.4
9.2.1
Consider UI architectures as alignment tools . . . . . . . . . . . . . . . . . . . 196
9.2.2
There is no "perfect" UI architecture . . . . . . . . . . . . . . . . . . . . . . . 196
9.2.3
UI Architectures and trends . . . . . . . . . . . . . . . . . . . . . . . . . . . . 197
9.2.4
Architectures can be formed over time . . . . . . . . . . . . . . . . . . . . . . 198
UI Principle 3: Imagine your feature as a Command Line Tool . . . . . . . . . . . . . . 198
9.3.1
A feature on the command line . . . . . . . . . . . . . . . . . . . . . . . . . . 199
9.3.2
Flexibility by disconnecting the business logic . . . . . . . . . . . . . . . . . . 200
9.3.3
Fat business domain, lean UI domain . . . . . . . . . . . . . . . . . . . . . . . 201
UI Principle 4: The UI does not dictate architectures in business domains . . . . . . . . 202
9.4.1
9.5
Supporting a variety of products and architectures . . . . . . . . . . . . . . . 203
What we covered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 205
10 Delivering reusable UI views; The art of decomposing a design
207
10.1 UI Principle 5: Name a view after what it is, not how you use it . . . . . . . . . . . . . . 208
10.1.1 Making a reusable view . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 208
10.1.2 Type name versus instance name . . . . . . . . . . . . . . . . . . . . . . . . . 210
10.1.3 Expressing reusable view in code . . . . . . . . . . . . . . . . . . . . . . . . . 212
10.1.4 View primitives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 212
10.1.5 The road of becoming too specific . . . . . . . . . . . . . . . . . . . . . . . . 213
10.2 Introducing abstractions without introducing new types . . . . . . . . . . . . . . . . 214
10.2.1 Abstractions via aliasing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 215
10.3 Creating the other view primitives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 216
10.4 UI Principle 6: Don’t name a view after its styling . . . . . . . . . . . . . . . . . . . . . 218
10.4.1 Alternative names . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 219
10.5 UI Principle 7: Favor composition over smart views . . . . . . . . . . . . . . . . . . . 220
10.5.1 Decomposing the TODO list . . . . . . . . . . . . . . . . . . . . . . . . . . . . 222
10.5.2 Domain naming versus view naming . . . . . . . . . . . . . . . . . . . . . . . 223
10.5.3 Handling inconsistent UI . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 223
10.5.4 Breaking a view down into smaller views . . . . . . . . . . . . . . . . . . . . . 224
10.5.5 Avoid delivering for hypothetical scenarios . . . . . . . . . . . . . . . . . . . . 225
vii
Mobile System Design
10.5.6 Button views . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 225
10.6 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 227
10.7 What we covered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 228
11 Reasoning about Views, Components, Screens, and Bindings
229
11.1 UI Principle 8: View components contain logic and/or bindings . . . . . . . . . . . . . 230
11.1.1 Distinguishing view primitives from view components . . . . . . . . . . . . . 231
11.2 UI Principle 9: Features can have local components . . . . . . . . . . . . . . . . . . . 233
11.3 UI Principle 10: Feature views are connected to business logic . . . . . . . . . . . . . 234
11.3.1 Connecting views to data . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 235
11.3.2 Bindings can come in many forms . . . . . . . . . . . . . . . . . . . . . . . . 236
11.3.3 A separation of concerns . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 237
11.3.4 Tradeoffs when using viewmodels . . . . . . . . . . . . . . . . . . . . . . . . 237
11.3.5 Deciding if you need more layers of indirection . . . . . . . . . . . . . . . . . 239
11.3.6 Imperative considerations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 239
11.3.7 Lean imperative code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 240
11.4 UI Principle 11: Feature views aren’t always full-screen . . . . . . . . . . . . . . . . . 242
11.4.1 Not every screen is necessarily a feature . . . . . . . . . . . . . . . . . . . . . 244
11.5 UI Principle 12: View components remain unaware of business logic . . . . . . . . . . 244
11.6 Is making a reusable component worth it? . . . . . . . . . . . . . . . . . . . . . . . . 246
11.6.1 Pitfalls of making a reusable component . . . . . . . . . . . . . . . . . . . . . 247
11.6.2 A heuristic for making reusable components . . . . . . . . . . . . . . . . . . . 247
11.6.3 We placed most views in a UI library . . . . . . . . . . . . . . . . . . . . . . . 248
11.7 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 249
11.8 What we covered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 250
12 Pragmatically implementing UI
253
12.1 Beginning the implementation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 255
12.2 Considering various approaches . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 256
12.2.1 A top-down approach . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 256
12.2.2 A bottom-up approach . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 257
12.2.3 A holistic approach . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 257
12.2.4 A mixed approach . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 258
12.3 How we’ll load the data . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 259
12.4 A SwiftUI crash course . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 259
12.5 Starting with CourseView . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 260
12.6 Simplifying view code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 263
viii
Mobile System Design
12.7 Implementing SelectionView declaratively . . . . . . . . . . . . . . . . . . . . . . . . 264
12.7.1 Defining SelectionView . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 266
12.7.2 Compile-time versus runtime . . . . . . . . . . . . . . . . . . . . . . . . . . . 267
12.7.3 Implementing SelectionItemView . . . . . . . . . . . . . . . . . . . . . . . . . 268
12.8 Implementing SelectionView imperatively . . . . . . . . . . . . . . . . . . . . . . . . 269
12.8.1 A UIKit crash course . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 270
12.8.2 Imperative code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 271
12.8.3 Implementing SelectionViewItem imperatively . . . . . . . . . . . . . . . . . 273
12.8.4 Updating state changes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 275
12.9 Adding the TODO List . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 277
12.10Implementing view primitives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 280
12.10.1 View builders . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 282
12.10.2 View primitives in the UI Library . . . . . . . . . . . . . . . . . . . . . . . . . . 283
12.11Implementing the actions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 284
12.11.1 Preparing for navigation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 287
12.12Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 287
12.13What we covered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 289
13 Delivering self-sufficient features, part I; The art of staying nimble
291
13.1 The problem of dependent features . . . . . . . . . . . . . . . . . . . . . . . . . . . . 293
13.1.1 Error-handling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 294
13.1.2 Handling errors more gracefully . . . . . . . . . . . . . . . . . . . . . . . . . . 295
13.1.3 Multiple simultaneous errors . . . . . . . . . . . . . . . . . . . . . . . . . . . 296
13.2 A self-sufficient feature in technical terms . . . . . . . . . . . . . . . . . . . . . . . . . 296
13.3 Connecting data-loading to a feature . . . . . . . . . . . . . . . . . . . . . . . . . . . 297
13.3.1 Extending to add functionality . . . . . . . . . . . . . . . . . . . . . . . . . . 298
13.3.2 Supporting optional data . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 299
13.4 Composition instead of extension . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 299
13.4.1 Nesting views . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 300
13.4.2 Swapping the names for consistency . . . . . . . . . . . . . . . . . . . . . . . 301
13.5 Dependencies and self-sufficient features . . . . . . . . . . . . . . . . . . . . . . . . . 304
13.5.1 Modularity and encapsulation . . . . . . . . . . . . . . . . . . . . . . . . . . . 304
13.6 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 305
13.7 What we covered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 306
14 Delivering self-sufficient features, part II; Self-loading features
309
14.1 Where we left off . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 310
14.2 A lightweight approach to connect data to UI . . . . . . . . . . . . . . . . . . . . . . . 311
ix
Mobile System Design
14.3 Supporting ID lookup . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 312
14.4 Maturing CourseAPI . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 314
14.5 Implementing the new CourseView . . . . . . . . . . . . . . . . . . . . . . . . . . . . 315
14.5.1 Three states . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 317
14.6 A self-sufficient feature in practice . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 319
14.6.1 A deep linking example . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 319
14.7 Supporting mutation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 320
14.8 Increasing flexibility by removing dependencies . . . . . . . . . . . . . . . . . . . . . 322
14.8.1 Replacing a property with a closure . . . . . . . . . . . . . . . . . . . . . . . . 322
14.8.2 The closure in action . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 324
14.8.3 Trade-offs when using a closure . . . . . . . . . . . . . . . . . . . . . . . . . . 325
14.8.4 Restoring API stability . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 325
14.9 Loading data granularly . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 326
14.10Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 327
14.11What we covered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 329
15 Delivering self-sufficient features, part III; Making features portable
331
15.1 Where we left off . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 333
15.1.1 Why the Course feature currently isn’t portable . . . . . . . . . . . . . . . . . 334
15.1.2 Why we currently can’t unit-test Course as a system . . . . . . . . . . . . . . . 335
15.2 Pushing the logic out of the UI domain . . . . . . . . . . . . . . . . . . . . . . . . . . 336
15.2.1 Introducing a source-of-truth model . . . . . . . . . . . . . . . . . . . . . . . 336
15.2.2 Moving logic to the model domain . . . . . . . . . . . . . . . . . . . . . . . . 339
15.2.3 Implementing CourseService . . . . . . . . . . . . . . . . . . . . . . . . . . . 340
15.2.4 Simplifying CourseView . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 341
15.3 Verifying our new implementation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 343
15.3.1 Detaching a feature from a screen . . . . . . . . . . . . . . . . . . . . . . . . . 343
15.3.2 Unit-tests instead of manually checking UI . . . . . . . . . . . . . . . . . . . . 344
15.3.3 Supporting other platforms and patterns . . . . . . . . . . . . . . . . . . . . . 346
15.4 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 350
15.5 What we covered . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 351
x
Preface
There’s no doubt that the term "System Design" has been popping up more and more in the mobile
engineering industry.
When mobile engineers apply for jobs, System Design is becoming more of a typical and standard part
of the interview process. Many engineers may be familiar with the process of stumbling upon System
Design books, only to discover that they cater to the needs of backend developers.
Unfortunately, the resources available specific to supporting mobile developers still remain limited
when it comes to Mobile System Design.
In addition, although we may have a general working definition of system design for backend development, there lacks a universally conferred understanding of what we actually mean when we say,
"Mobile System Design."
When we lack aggregate knowledge on the subject matter and pair this with fuzzy definitions, we
decrease our likelihood of success as mobile developers.
Furthermore, in the mobile industry, there is a strong focus on the latest trends, delivering the shiniest
UI, and engaging in the favorite pastime of squabbling amongst each other about which UI architecture
is the "best."
But, in taking a step back from this all, our emphasis and attention could be more effectively directed
towards improving our fundamental skills. Skills that are more timeless. Skills that go deeper, beyond
beginner tutorials or fleeting trends.
This is what I hope to change with this book.
When we produce small apps as indie developers, we don’t always require a technical design upfront.
But, for regular jobs where you work in teams, mobile apps are typically larger and absolutely more
complicated.
As soon as apps grow more features and become more complex, strong fundamental skills become
ever more crucial. This is where knowledge about Mobile System Design is imperative and effective
especially given the ever-changing landscape and fast-paced environment of mobile engineering.
In my experience as an iOS Tech Lead at ING international Bank and staff engineer at Twitter (before it
became X), I noticed certain patterns where developers – myself included – find it increasingly more
1
Mobile System Design
difficult to deliver features as soon as apps grow in size and complexity.
To handle complexity, developers look for crutches, common patterns, or architectures to wriggle
themselves out of problematic situations.
In reaching for heuristics, or rules of thumb, many developers often rely on SOLID principles. However,
in going against the grain, I find it too outdated to keep up with modern demands. Unless you’re
really into subclassing and using interfaces to over-abstract your code, SOLID principles should be set
aside.
Another related issue with crutches is the fact that some may call their own code "clean code", without
actually checking if their coworkers find it readable or easy to comprehend.
The programming world is rapidly evolving. Today, software design favors composition over subclassing
to handle complexity and we are entering the realm of using declarative frameworks to create UI, with
mostly positive results. This affects how we design our apps.
One main reason this book exists is to assist with job interview preparedness. At the time of writing,
the market took a big downturn and many people, unfortunately and unexpectedly, lost their jobs.
When later churning through repeated rounds of interviews, many have realized that System Design
has become an integral step in the interview process.
The aim of this book is to provide people with the necessary skills and knowledge to help increase
their chances of getting the job that they really want.
However, this book isn’t aimed solely towards mobile engineers taking part in future interviews.
This book is also geared towards supporting and improving day-to-day work activities, given that
strong technical design skills have the capacity to make positive, longstanding impact on one’s work
experiences.
Luckily, I was in a unique position to make this book a reality based on my past professional endeavours.
I wrote a technical book before, Swift in Depth, published by Manning and have worked on multiple
large-scale mobile applications for large-scale international businesses situated in Europe and North
America. I have been a mobile developer for nearly 14 years, built features for over 24 years on different
platforms, and I have been on both ends of the job interview process for mobile developers.
I wanted to take my years of experience and consolidate this plethora of knowledge into insights on
mobile app development that can be accessibly shared with others. In light of these efforts, I’m now
happy to say that there finally exists a Mobile System Design book available for mobile engineers.
That means that this book isn’t a quick bite-sized snack. It does not contain quick-and-dirty tips that
you may often find in blog posts or the many beginner-focused tutorials. There is a time and place for
that, but that’s not this book.
2
Mobile System Design
In this book, you will do what you probably are already doing: Making features and apps. But, we are
going to go deeper at every step to refine your skills.
The core message of this book is that you become a better developer by working on your fundamental
skills. Forget the fancy tools and frameworks for a moment, and let’s get back to the basics. The
takeaways from this book will last your entire career and make you a better developer in many critical
aspects.
One way that this book stands out is that it keeps team dynamics in mind. It does not solely focus on
the technical challenges. For instance, deciding what to prioritize often isn’t a coding question. It’s
about how to make a team move faster, not just yourself. As you’ll discover, sometimes focusing on the
boring parts can make your team deliver faster as a whole.
This book is opinionated with a strong focus on keeping it simple. The book will often use one approach
instead of showing the pros and cons for every approach. The benefit of doing so is that it allows us to
go in-depth about certain topics. Expect to disagree sometimes. This is normal and I encourage you to
be critical. But please, try to be open and see if you can learn something in every step of the process.
A goal of mine was to avoid making this book too high-level, focusing only on diagrams. It’s a good
idea to design your software with diagrams, but it’s not necessarily enough.
When you write the actual code, you think of problems in more detail. That’s when we realize that
high-level diagrams don’t always reflect reality. Designing your app is important, but being able to
go in-depth and identify issues in your design is part of that. Hence why this book will often go from
high-level design, to the nuts and bolts on a code-level, and back again.
I hope this book can help you improve your career prospects and your confidence as a mobile developer
and I hope you have a lot of fun and insights reading it.
0.1 Acknowledgements
I want to give special thanks to Dimitar Gyurov, Marie Denis, Elvirion Antersijn and Nicole Yarroch for
reviewing the chapters. Thanks to your help, I made chapters more accessible and clear.
I want to give special thanks to Donny Wals for being my sounding board. You gave me more perspective
on the same problems that we run into during day-to-day mobile work.
Thank you, Leo G Dion for making people aware of my book and being so supportive of it.
Thank you, Hugo Visser for giving me deeper Android insights.
I want to give thanks to Martin Lechner and Cristian Caroli for their support while writing this book.
And special thanks to Jenika, my wife, for being so supportive and patient while this book kept me
mentally occupied.
3
Mobile System Design
0.2 This book is an early release edition; What to expect
Thank you so much for your early interest in this book. You’ll be one of the first people to read and
absorb the concepts shared in here.
Before you continue, I’d like to share a few things to keep in mind since this book is in beta.
First, I will add more chapters as soon as they come out. Expect book updates with new sections, better
phrasing, updated code listings, updated diagrams, and so on.
Expect typos, odd phrasing, and code-listings to be cut off awkwardly. Because the copy-editing
happens after all writing is finished.
The chapters aren’t set in stone, either. Sections, paragraph, and sometimes even entire chapters
might get updates.
For example, maybe a section will be rewritten because it’s not clear enough. Or maybe an unwritten
chapter is replaced by a different one.
The chapters that are already written won’t just disappear. But the unwritten chapters are more
susceptible to change to help the flow of the book. This is a normal process of writing a book.
And last, the illustrations are not all final.
If you find any other issues, errors, or any wrong statements, then feel free to reach out to
tjeerd@swiftindepth.com, or DM me on Twitter/X @tjeerdintveen.
When chapters or sections aren’t clear enough, please let me know! I will gladly use your feedback to
improve this book.
Enjoy!
– Tjeerd
4
1 About this book
If you ask mobile engineers what Mobile System Design is, you’ll get a wild variety of different answers.
Some might say it’s about UI Flows, others about job interviews, or they will mention it’s about UIarchitectures. Yet, others may tell you it’s about domain modeling or dependencies, or they’ll say it’s
about a modular codebase.
The term “System Design” is usually reserved for backend engineers and often associated with job
interviews. During these interviews, a backend engineer would design a technical solution to solve a
problem that the interviewer gives.
For example, an interviewer might ask a backend-engineer: “How would you implement a livestream
chat-service?”. The candidate draws a graph of the services needed and explains how data flows
between them. They answer questions about scaling up, storage, redundancy, and more..
Sometimes, the candidate would write (pseudo)code to explain the required types to make it all work
together.
In the mobile world, we see an increasing amount of System Design interviews pop up, too. Yet the
problems differ from the more traditional System Design challenges given to backend engineers.
During a System Design interview for mobile engineers, the question might be similar: “How would you
build a feature that does X”.
But you’d be focusing on designing the architecture for a client app with mobile-specific constraints,
such as, but not limited to:
• Designing complex flows while considering limited screen space.
• Making a feature work offline, yet keep data in sync with the back-end.
• How to avoid hammering the servers while downloading large amounts of data.
• Handling networking and local cache invalidation.
• Making a feature reusable across multiple devices and platforms.
5
Mobile System Design
Despite the association, system design is more than just a step during the job interview process. It’s
about the ability to create a solution to cater to the requirements of a business. Both at work and in
job interviews.
A system can be small. Perhaps your work today is making a tiny app with three screens. But it can
also be gigantic; Some apps are a multi-modular codebase where hundreds of developers are working
on the same codebase and have to share a lot of libraries together.
1.1 System Design versus Software Architecture
The term "System Design" is nebulous, because it touches upon many overlapping software design
practices that fall under the umbrella of Software Architecture, such as: domain modeling, architectural
patterns, API design, or component design.
With this book, we try to define System Design as designing a technical solution to satisfy business
requirements.
To put it simply: You receive requirements, and you have to figure out what components to make and
how they work together to solve the business’ needs.
Specifically in this book, we narrow it down to business requirements, since it mostly reflects requirements found at work. As opposed to any software requirements, such as hobby projects and toy apps
where a lot more rules can be broken.
Another way to think about System Design is coming up with the components and their APIs to solve a
problem.
1.2 Why is System Design important?
Software is expensive.
It’s important to reduce the time or sprints of a project, and having the ability to adapt swiftly to new
demands, features, and requirements. Even being able to even delete entire chunks of your application
is crucial.
Modern software moves fast, and mobile apps especially so. On top of a rapidly-changing platform, we
have to be able to adjust and deliver a product to help solve our customer’s needs.
We have to be ready for any changes and we should aim to avoid wasting time building features nobody
uses.
6
Mobile System Design
But being nimble is not always possible when we’re working in a rigid system, or a codebase that easily
breaks.
Mobile System Design helps ensure our apps are of high quality, developed quickly, and can be adjusted
where necessary while managing a growing codebase.
1.3 Common challenges for mobile engineers
Some might quip that mobile apps are JSON viewers—of which I am also guilty when feeling snarky.
Once you go beyond toy-apps however, system design and a strong architecture do become critical for
a mobile app.
Mobile development poses interesting challenges. As a team, you’ll be shipping one binary. Your focus
is more on local architectures and delivering code that’s shared by the entire team.
The larger the app and teams, the more teams have to think about—and struggle with—topics such
as:
• Delivering shared components that need to meet company-wide requirements.
• Taming abstractions and keeping complexity low.
• Over-engineering.
• Too much, or too little, code duplication.
• The danger of a convoluted codebase.
These problems are not unique to mobile development. It is, however, a situation mobile engineers
quickly run into because they are shipping one binary together with all features combined; As opposed
to, say, a web-team that can ship features independently.
You might choose to decouple features into their own namespaces, packages, libraries, SDK’s, frameworks, and modules; However, all these pieces will still have to get along, since you’ll be glueing all
these “independent” parts together as a whole in the shape of an app.
Often, these independent parts are relying on components shared by all other features, such as UI components or shared business logic; If you’re not careful, changing one element in a shared component
can affect all features in the entire app for all mobile teams.
Keep in mind that there is no shame in relying on shared code either. When implemented well, it can
give you engineering superpowers; Such as implementing a shared module that contains a UI Design
System. This system would contain all the components that power up the features we make, saving a
team tremendous time.
7
Mobile System Design
To get more autonomy, developers might choose to split up features into modules. They might wonder
how many modules an app needs and how much they should rely on interfaces or even interfacemodules. Splitting up your codebase into modules increases the need for strong API design and
thinking a bit differently about components. When poorly implemented, a modular design will even
backfire.
Whichever way you go about it; As mobile engineers we have to find a balance between shared components, modules, abstractions, architectures, handling dependencies, and everything in-between. All
the way from a monolithic app to a giant modular codebase. This is where this book can assist you.
1.3.1 Rapidly changing environment
Another challenge that mobile engineers face is that the mobile industry moves fast.
New devices get released every year, and major OS updates bring new UI and exciting functionalities.
Sometimes, we even get to deliver products for entirely new platforms, such as watches, TV’s, or VR
glasses.
On top of dealing with foundational changes, businesses constantly require changes to serve customers.
As a result, a mobile codebase is ever-moving, and frequently shifting.
On average, the UI aspect of mobile apps changes more often than foundational code for app developers. Because mobile apps are customer-facing and we have to keep up with the latest advances that
come with OS updates.
Conversely, code that’s more foundational, such as making a network calls or storing locally protected
data, can often keep working regardless of the UI solution.
Since the UI layer tends to be more volatile for mobile developers, the book focuses on building a firm
foundation. This way, you’re ready to handle many changes to UI. Such as new OS updates, changing
UI architectures, adapting to UI frameworks, and you’ll even be ready for entirely different products;
Such as tablets, watches, TV platforms, or VR.
1.4 Why does this book exist?
This book is an answer to mobile engineers who struggle with repeated and common issues during the
mobile development process.
8
Mobile System Design
Over time, being asked to implement a feature transforms from “Sure! I’ll have it done by this afternoon!”
to “I’d first have to check with Steve but he’s on vacation and we first need to refactor this other piece of
code, but that code is in the middle of a migration so I can only make a temporary fix.”
Or: “I can’t move forward because we rely on version 2 of this third-party SDK, and we need version 3.
But, version 3 has a singleton that makes it almost impossible to test this flow, so we need to wait for a
change”.
These issues are never a specific problem with a clear silver bullet answer. “Oh, if only we had used
reactive programming, our app would be so much easier to work with!”.
Unfortunately, challenges with mobile development are more nuanced.
Many engineers’ struggles are a buildup of smaller issues that are introduced during the development
process.
Individually, these issues aren’t a big deal and easily slip under the radar during code reviews. A
little abstraction here, a small “temporary” fix there, some duplicate code here. But now, these tiny
problems turn into a tedious codebase that’s as much fun to work in as trimming a cat’s toenails.
It’s death by a thousand cuts.
Development-speed slows down, tech-debt increases and people might start saying the R-word (refactoring), worrying managers who’d rather have engineers focus their time on shipping features.
In this book, we’ll make smarter, more robust decisions that will lower the chances of our codebase
turning into a “big ball of mud”.
NOTE: “Big ball of mud” is a term for complex technology that lacks structure and design. The term was
popularized in Brian Foote and Joseph Yoder’s 1997 paper of the same name.
1.5 Why this book’s subtitle is called ’Resourceful Engineering’
Looking up the definition of resourceful, we get:
as in skilled. “able to deal well with new or difficult situations and to find solutions to problems”
• merriam-webster.com
“Someone who is resourceful is good at finding ways of dealing with problems.”
• collinsdictionary.com
“able to deal skillfully and promptly with new situations, difficulties, etc.”
• dictionary.com
9
Mobile System Design
As a developer at a company, you receive requirements; you don’t have the full information, and yet
you must deliver. Even when you get an extensive list of requirements and many designs, there always
are unknowns. You’ll have to be ready for that.
Resourcefulness is something you are yourself, but it affects yourself and your team. You must be
resourceful in developing software and features.
Once you’re ready to develop, you need to be adaptable.
Customers, managers, and businesses can change their minds and our solutions may not work anymore.
You must remain flexible and keep momentum.
On a technical level, you need to make sure a codebase is flexible enough. But, you also don’t want
to make a codebase too flexible. Once you develop solutions that “might be useful later”, you risk
over-engineering. It’s a harmful practice that can make a codebase overly complicated, but it’s very
nuanced and hard to balance.
The book will break down “resourcefulness” into tools and strategies. It will help you become a
resourceful mobile engineer.
This book puts a strong emphasis on being able to move forward without knowing all the information.
A guiding topic is to keep your code flexible and simple without over-engineering. This way, you can
confidently deal with unforeseen changes and keep a simple, yet adaptable, codebase.
1.6 What to expect during Mobile System Design interviews
The book’s focus is not limited to passing interviews. But all the techniques inside will be extremely
helpful for the interviewing process.
During Mobile System Design interviews, you’re tasked to design a feature or functionality. You do this
by drawing diagrams or writing out (pseudo) code.
During this time, interviewers are looking for so-called “signals”. These signals are indicators that will
tell them you are at the level that you’re applying for.
If you’re applying for a junior, SW I or SW II level, job, you can expect to receive UI and feature specifications to come up with a technical design. For example, the interviewer might ask: “How would you
build a screen that downloads and displays a list of workouts in a gym app?”
While modeling this feature, you’ll cover topics such as networking, testing, and how to decouple UI
from business logic.
10
Mobile System Design
When interviewing for a senior level, expect the questions to become more broad or “larger scale”.
The interviewers might look for signals that you deliver a feature that serves larger flows and multiple
use-cases.
Alternatively, they might ask how you would make a feature reusable across the entire application
or multiple targets. This will touch on topics such as architectures, interfaces, generic code, domain
modeling, and dependency injection. You can also expect to be asked about more complicated topics,
such as downloading large amounts of local data, caching, or security.
If you’re applying for an even higher position, such as a staff engineer, expect to go more grand-scale.
At this level, expect having to come up with solutions ranging from building features all the way to
release management.
Interviewers may ask what is required to scale up the feature so that it can serve all teams, multiple
targets, platforms, and domains.
The questions at this stage can go deep, such as writing generic, reusable components.
Alternatively, questions can go wide. Such as explaining how to introduce a large feature that requires
a tremendous effort from many teams. They may ask you what a highly modular architecture would
look like for a decoupled codebase suited for an entire organization.
If you’re preparing for an interview, this book will help you with a deeper knowledge of common
interview questions. It will help you ask better questions while being briefed — signaling to the
interviewer you are capable of thinking about a problem on a deeper level.
Once an interviewer asks you to create a program from scratch, then this book will have your back. In
the Holistic-Driven Development chapter, you will see how to get something up and running quickly.
A common theme for Mobile System Design interviews is architectures, interfaces, domain modeling,
testing, and dependency injection. The book covers all these topics are in depth.
A big bonus that this book offers is that you don’t need to rely on third-party frameworks as crutches
to achieve excellent results. You’ll be prepared in an interview setting where code will be vanilla and
first-party, such as coding in a web browser.
1.7 What this book is not
This book focuses on feature-development, mimicking daily work for the mobile engineers. The book
starts by building a local feature until you reach a larger scale where the book covers topics such as
designing domains suited for an entire app and delivering a modular app.
However, in this book you will not make an app that’s feature-complete and ready to ship. This book is
about processes, ideas, concepts, mental models, and avoiding pitfalls.
11
Mobile System Design
One aim of this book is to help you pass those System Design interviews. However, this book is
not a template for System Design interviews, where one might expect prepared answers to common
questions. The book isn’t here to provide you with a generic script to regurgitate back to interviewers.
For example, the interviewer might ask, “How would you store remote data locally?” and you might
share trade-offs between a local database versus a key-value store versus using a secure storage. That
is definitely a useful topic, but it can only cover rehearsed problems.
This book is about learning to reason so you can design any feature that’s thrown at you at work or
during interviews.
For example, in the briefing chapter you are given a feature, but instead of giving you common answers
– e.g. “Use a database for large data but a key-value store for a simple dictionary” – the chapter is about
asking the right questions back to the person briefing you.
By asking questions, it will signal the interviewer that you’re getting to understand a problem on a
deeper level. During daily work, it will help you find a better solution to your problem.
Sure, maybe today the problem is offline storage and you can give some rehearsed answers to that,
but tomorrow the problem could be something completely different, maybe you’re tasked to make an
AR video solution where you may not even know whether there’s an SDK for that, yet. By the end of
this book you’ll have more confidence to tackle unknown problems.
Since the book can’t cover all problems that ever existed or will exist, the book focuses on giving you
tools to handle most problems that are given to you.
1.7.1 This book is not a traditional programming book
This book is also not a “code book”. It contains code, and in some chapters more than others, but only
to some extent. A lot of pages are more filled with reasoning about what we’re making, and less so
about code tips-and-tricks to build things.
Luckily, the book won’t keep its advice generic and shallow. It demonstrates how to do everything in
code where needed, just not 100% of the time.
This book will not cover UI styling and animations. Despite that, it has a strong focus on UI from a
system perspective; You will learn how to best decompose UI into components. You’ll even go as far as
designing a UI library powering a Design System. We will cover architectures and their roles, and you
will implement a feature in an architectural sense combined with domain modeling.
Last but not least; This book focuses on native development. Releasing a hybrid app that’s shipping with
web-technologies is outside this book’s scope. Keeping the project native is, to be honest, something
most mobile engineers can appreciate.
12
Mobile System Design
1.8 This book is about timeless principles, not trends
One of the key messages in this book is that you can get a lot done with plain, vanilla, first-party code.
Forget trendy third-party frameworks and architectures for a moment. With some sane programming
and safeguarding the code quality, you can deliver a giant mobile application.
Not too long ago in our industry, Photoshop and Illustrator got replaced by Figma and Sketch, and so
will the next best thing replace them. Objective-C and Java are still in use, but they have to make way
for Swift and Kotlin.
Nowadays, some might say UIKit and XML layouts are outdated, and may consider SwiftUI, Jetpack
Compose, and Flutter the future. Who knows, maybe one day instead of writing native apps for iOS
and Android we’ll write a new thing that runs on anywhere, let’s call it mobile-assembly.
Change is inevitable. But, despite tools and trends changing around us, the process will be similar.
You’ll deal with companies or people having an idea to produce something. You’re alone or in a team,
you receive designs and specs, and you need to deliver and focus on the right things.
After releasing an app or update, you will update and maintain that code. It doesn’t matter whether
you use Swift or Kotlin or Kabomaflug (a new language I just made up), the concepts will be similar.
Some engineers may follow trends but struggle with foundational problems; They may use the latest
and greatest frameworks, but their app might be a spaghetti code-mess underwater. Or they’d religiously program in a reactive way, but can’t help but over-engineer their “pure functional programming”
codebase. This book will help you avoid these pitfalls.
1.9 This book is for iOS, Android, and multi-platform developers alike
This book uses Swift as a vehicle to explain concepts and best practices, and it’s not relying a lot on iOS
specifically. This book focuses mostly on concepts, mental tools, reasoning, and approaches.
With some basic programming knowledge you’ll be able to understand the Swift code examples with
ease.
We won’t go too deep about platform or language-specific requirements. Where needed, the book
explains specific Swift keywords.
So whether you’re an iOS engineer, Android engineer or use Flutter, React Native or other mobile
platforms, you can apply the knowledge from this book.
13
Mobile System Design
1.10 Is this book for you?
This book assumes you’re serious about developing apps, whether that’s by yourself or within a team.
The book does assume you will work with others, but it doesn’t matter whether you’re in a tiny startup
or in a giant mobile department in big tech.
It’s written primarily with junior and senior developers in mind. I’d argue that staff-level engineers can
get plenty of value out of this book as well, depending on the background.
This book does go over the process that you’re already familiar with, but aims to give you new perspectives, tools, and considerations at each step, so that you come out a better developer at the
end.
If you’re never made an app before, you can use this book to get some ideas on what starting a project
from scratch would look like and how you can work in a mobile team. But it will not show you how to
set up a project.
1.11 How this book works
This book starts by being briefed and receiving designs, and we build from there. So there is a natural
flow to build something from scratch, and during this book’s progression, this feature (and app) will
grow.
If you want to jump to a specific topic, such as testing, you can. Just note that the chapters build on a
feature that grows throughout the chapters.
1.11.1 A strong focus on building the right things
Building the wrong things fast is worse than building the right things slowly. It’s easy to get distracted
by details and losing focus.
Delivering fast means spending your time and energy in the right places. Because of that, this book
will put a strong emphasis on keeping momentum and making sure we keep our focus.
14
Mobile System Design
1.12 The chapters
1.12.1 Chapter 1: About this book
This is the chapter you’re reading now. Where we cover the challenges of mobile development and
how System Design can help.
1.12.2 Chapter 2: Turning a briefing into a strong plan
In this chapter, you’ll get briefed for a feature that will be the key feature used throughout this book.
We’ll reason about finding requirements, both obvious and hidden, and come up with a plan to implement it.
You’ll learn how to sketch out a technical design while missing half the information.
The chapter covers talking to designers, backend developers, and other ideas to get a complete picture
of what you’ll be making.
If you’re preparing for job interviews, then this chapter will be useful to you.
1.12.3 Chapter 3: Holistic-Driven Development; Turning a plan into code
In this chapter, you’ll learn on how to take a design and use that to deliver a feature. You’ll do so by
defining the components and designing the interfaces, or APIs required.
We’ll take a holistic approach. Meaning that you’ll jump between the components that you create, to
end up with a working prototype. This approach helps you to deliver quickly and ensures you will keep
the highest priority in mind.
This chapter is more code-centric; We’ll go into API design, data modeling, and domain modeling.
It has a strong emphasis on designing without interface types and explains why.
You’ll learn how to implement a robust feature quickly, without getting distracted by the details.
At the end of the chapter, we’ll go over trade-offs and downsides of this approach to get a deeper
understanding of when to apply this technique.
This chapter is crucial if you’re a feature engineer or applying for jobs.
15
Mobile System Design
1.12.4 Chapter 4: System-wide testing; Delivering higher quality apps
This chapter challenges the status quo in testing.
A common approach to testing is to use many interfaces and test the smallest units.
But in this chapter, you’ll see how to test using fewer interfaces, allowing you to test the system on a
larger scope.
The reason we do this is to get more quality guarantees early on in the development process. This
chapter explains why this is important for mobile development.
As a bonus, you will learn how to test more by writing less code.
Like all testing approaches, neither is this approach a silver-bullet solution. But, we’ll cover the tradeoffs and you’ll discover some techniques to handle its downsides.
1.12.5 Chapter 5: Cross-domain testing; Testing more with less effort
A major benefit of system-wide testing is that we can test across multiple domains more easily. We’ll
exploit that benefit to spend even less time to test more.
In this chapter, we’ll reason about which domains are more volatile and which are more stable. To help
you figure out where to invest your time and energy for writing tests.
It covers where to fix issues if any arise in our entire program, and the responsibilities of a domain
when testing across them.
1.12.6 Chapter 6: Dependency injection foundations
This book has three chapters dedicated to dependency injection. This is because it’s a contentious
topic that many developers struggle with.
We start with the basics of why we need dependency injection. It covers some common scenarios that
can be harmful.
One big part of this chapter is dealing with singletons, since they are common in mobile development.
It might be common knowledge that they are harmful. But this chapter offers some fresh perspectives.
Many developers like to modularize their code. This chapter shows the negative effect singletons have
when you wish to modularize your code.
16
Mobile System Design
Another issue is that developers sometimes think that making singletons thread-safe is enough. But
this chapter will show easy it is to break them in a real-world scenario, even when they are perfectly
thread-safe.
Lastly, we will cover scenarios when singletons do make sense and are a good idea to implement.
1.12.7 Chapter 7: Sane dependency injection without fancy frameworks
In this chapter, we’ll start making an implementation for the feature in this book.
The goal of this chapter is to show you how to pass dependencies around without any fancy techniques
or frameworks. The goal of this chapter is to show that we can keep it relatively straight-forward and
simple.
1.12.8 Chapter 8: Dependency Injection on a larger scale
In this chapter, we handle dependencies in larger code-hierarchies.
You see how to reduce a giant dependency tree to make the problem smaller and more simple. By
doing so, you’ll learn how to weave dependencies across many types.
Then we go one step further; Handling dependencies in a modular app.
Because when an app grows, people tend to modularize them. But, when we are passing dependencies
across modules, we have unique problems to consider. This changes the way we reason about our
dependency solutions.
1.12.9 Chapter 9: UI frameworks, architectures, and supporting multiple products
In this chapter, we will enter the phase where we’ll implement UI.
But, before we do so, this chapter will cover how to reason about a feature so that it supports any UI
architecture or framework.
We’ll cover why UI architectures aren’t as important as you might think.
The chapters will go over the benefits when you delay an UI implementation. You will learn about the
benefits you get when you treat your features like a self-sustained command line tool.
Lastly, it will cover how to reason about a codebase when you want to support a wild variety of UI
architectures, frameworks, and multiple products.
17
Mobile System Design
1.12.10 Chapter 10: Delivering reusable UI views; The art of decomposing a design
Turning a design into views is a common task for mobile engineers. But, this chapter will show you
how to do it in such a way so that you not only deliver your feature, but you will develop a UI library
without extra work.
A big topic of this chapter is naming, and you’ll see why it’s crucial for delivering components. It encourages us to think about important concepts like abstractions, reusable components, over-engineering,
and long-term thinking.
It’s an important chapter for any mobile developer, and should help you make better components for
mobile and other platforms.
1.12.11 Chapter 11: Reasoning about Views, Components, Screens, and Bindings
After breaking down the design, we end up with various views where some of those live in a separate
UI library.
In this chapter we’ll determine the approach for connecting, or binding, these views to our business
logic to create the course feature.
This chapter covers UI patterns, emphasizing viewmodels as a common example. It will assist you in
determining whether a UI pattern is necessary at all. Then, we’ll make different considerations for an
imperative approach, too.
After that we look into more philosophical questions, contemplating the roles of views, where we
should place business logic, and how views are related to "screens".
This will equip you for the development of more intricate screens, showing that UI patterns are merely
one means of connecting views. It will aid you to create user interfaces that are both simple and
complex.
Finally, the chapter discusses the components and their role. We will dissect the nuances to help you
decide when it is appropriate, or not, to create a reusable view component.
1.12.12 Chapter 12: Pragmatically implementing UI
This chapter takes a practical approach by guiding you through the step-by-step implementation
of a screen based on the initial briefing in this book. Its primary objective is to provide a detailed
examination of considerations at the code level.
Assuming you have experience with typical UI implementations, the chapter also shares insights on
maintaining a productive and efficient development process.
18
Mobile System Design
Exploring both top-down and bottom-up approaches, it discusses their relationship with Holistic-Driven
Development.
A key insight emphasized is that, despite significant differences between declarative and imperative
programming, the approaches in these paradigms can be remarkably similar.
Demonstrating this point, the chapter showcases the implementation of the same component both
imperatively and declaratively.
1.12.13 Chapter 13: Delivering self-sufficient features, part I: The art of staying nimble
In this chapter, we’ll look at why self-sufficient features are important.
Self-sufficient features are features that can load themselves, handle their errors gracefully, and don’t
interfere with their parent’s navigation by mutating their stack.
After explaining the concept and benefits, the chapter will share some high-level designs we’ll apply to
the project in this book to make a feature more self-sufficient.
This chapter will be part I of a brief series. Meaning that we’ll spend a few chapters to fully grasp the
concept. For instance, in the subsequent chapter, we will take a code-level look at the decisions we
make in this chapter.
1.12.14 Chapter 14: Delivering self-sufficient features, part II; Self-loading features
Continuing with the self-sufficient features series, we’ll look at how to ensure a feature can be selfloading and the advantages it brings.
You’ll learn how to make a feature that you can “cut and paste” across an app which makes it easier to
move around, including across modules. As a bonus, this chapter will show you how much easier it is
to support deep-linking to a screen.
To achieve this, we’ll look at supporting ID lookup and how a view can load and mutate data. Then,
we’ll look at a different approach where we’ll make a view more flexible and reason about its dependencies.
Last, we’ll briefly cover how to apply this method to features that can partially load themselves, such
as using a pull-to-refresh mechanism.
19
Mobile System Design
1.12.15 Chapter 15: Delivering self-sufficient features, part III; Making features
portable
Continuing with the self-sufficient features series, we’ll look at how to ensure a feature can be
portable.
A portable feature is another way of saying that a feature works regardless of targets, platforms,
architectures, and UI patterns.
Portable features bring us a lot of benefits; First, it allows us to deliver features that can survive UI
migrations, such as moving from UI pattern to a new one, or moving from one paradigm to the next.
Besides that, portable features enable us to disconnect a feature from a screen, ensuring it can work in
the background.
But perhaps most importantly, portable features allow us to unit-test a larger surface, so that we can
rely less on UI Tests or manual verification for the same quality guarantees.
In this chapter, we’ll look at how to make features more portable by pushing more logic out of the
UI domain into the model domain. We do this by taking a detailed code-level look at how to achieve
this.
20
Mobile System Design
1.12.16 Upcoming chapters
The upcoming chapters are being written as you’re reading this. They aren’t final, but expect to see
chapters related to:
• Navigation and UI flows. Making sure flows are easy to set up and use across an application.
• Creating a design system – not to be confused with system design – where we create a library of
reusable components for larger apps.
• Designing large-scale app architectures.
• Quality assurance: The role of UI Tests, integration tests, and manual testing. How to use these
techniques for a higher quality app.
• A summary of all lessons in this book and how to apply them to your own work.
21
Mobile System Design
1.13 About the author
Tjeerd in ’t Veen is a mobile developer since 2010, with a long history in iOS and working closely with
Android developers. His career includes being a staff engineer at Twitter 1.0 and a iOS tech lead for the
ING international bank. He has been delivering features across various platforms since 2000, and has
been involved in hundreds of projects.
Tjeerd wrote the highly rated book called Swift in Depth, published by Manning.
You can find his blog on https://www.swiftindepth.com. You can find him on Twitter, now X, at
@tjeerdintveen or on Mastodon at @tjeerdintveen@mastodon.social.
When he’s not working, he spends most of the time being a family man, a dad of two daughters, and
noodling on a guitar.
22
2 Turning a briefing into a strong plan
In this chapter
• Being briefed for a new project
• Creating an approach from a lot of unknowns
• Ending up with a starting-point
• How to get a fuller feature set from a partial briefing
• How to make sure we focus on the right things
• Avoiding pitfalls when planning a new feature
It’s a common routine: We receive some designs, some (vague notion of) requirements, and then we
are asked “Can you make this?” followed by the fan-favorite “When do you think it will be finished”?
Somewhere in an alternate universe during a Systems Design interview, the question is not “Can you
make this?” but “How would you make this”? After which you’ll have to describe your thought process
of turning a design and specs into bits and pieces and sometimes even pseudo-code.
Meanwhile, two interviewing engineers will look at you pensively, sweat drips down your forehead
because even though it’s again the same argument about how import UI is (not) allowed in a viewmodel, giving a good answer could mean you get a cool job and maybe even relocate your entire family
to a new country. . . or not. The pressure is on.
It doesn’t matter whether we’re talking about a full-sized app or a tiny feature. Every time we get a
design, we have to figure out how to turn pretty images and cool ideas into a real – albeit intangible –
thing.
To some, it might seem easy when all we have to make is a tiny component in a toy app or tweak a
button’s width; But as soon as we are making an entire tech stack with abstractions, domains, and
components shared between numerous features and teammates, then the landscape changes and we
have to think differently.
Not to mention that you’re in for a treat when dealing with (smelly) legacy code that nobody dares to –
nor wants to – touch.
In this chapter, the book briefs you in a similar fashion. You will receive a design and then we are
going to cover approaches. After that, we will try to get more (hidden) information to get a complete
picture.
23
Mobile System Design
The goal of this chapter is to give you mental models on how to receive specs and a design. Then you’ll
end up with a sound plan for delivering the components needed for this feature.
But, we will start small for a couple of reasons:
• First, by starting small, we can focus on the process in a smaller setting, which you can replicate
in both small and bigger applications.
• Second, to match the real-world experience; Often inside a company or during a System Design
interview, you will be asked to work on a smaller feature-set consisting of only one or a few
screens at a time, not a giant app from scratch.
2.1 The briefing
Together we’re going to work on an app where a student can connect with tutors to learn new skills,
such as learning to play guitar or learning to speak Italian.
The app is unreleased, but we can assume the project already exists so it’s not 100% from scratch. We
don’t have to worry about setting up a new project.
With this product, tutors or coaches will check in with students via 1on1 calls. Between these 1on1
sessions, students would follow a plan – offered by the tutor – consisting of assignments or exercises
to make sure they keep on track and keep improving.
Tutors will be the ones making these tailor-made assignments and can give feedback when needed.
Now imagine you’re joining my team to build this, and I’m giving you a screen for this app, asking you
to implement it. Don’t worry, we’ll do it together.
Here we can see the main screen a student would see. It has assignments, tutor information, a way
to reach out to the tutor, some scheduling information, and a worksheet or todo list of recurring
activities.
24
Mobile System Design
25
Mobile System Design
In the tab bar at the bottom we see more features in this app, but we don’t have to worry about the
rest of the app for now. With just this screen, we can apply a lot of principles.
2.1.1 An initial impression
Looking at the design, we can see it’s already quite high fidelity.
NOTE: A high fidelity design is a term used by designers to indicate a design that’s close to the final
product. As opposed to low fidelity, which is more conceptual and wireframe-like.
This screen alone may look simple at first sight, but has some not-so-obvious features to make everything work.
For example, there is quite some linking going on; There are TODO items, and they have arrows hinting
at details. This screen can open other features such as messages and opening a call. At this moment,
we don’t know if those buttons add views to the current stack, or if it’s a deep link to a distinct part of
the application. Perhaps the buttons link to entirely different apps.
Next, notice how this screen is populated with properly filled content. But what if we don’t have
anything scheduled? Or what if the tutor forgot to add TODO items? Or what if the tutor didn’t add a
callout message? Will this screen just be mostly empty space?
Notice how some of the TODO items are daily. Who resets these daily items? Perhaps the user does
it manually, or should these items auto-reset themselves locally in the app? Or perhaps the server
should auto-reset them?
All these things are unclear for now.
Next, what about tablet-support, or landscape mode, or dark/night mode? What about the data?
Where does it come from? What about local storage or caching? What about error handling?
These are just the initial things we know we don’t know. What about the unknown unknowns? We don’t
know what else we haven’t thought about.
Long story short: There are quite some known unknowns and unknown unknowns at this stage.
2.2 Evaluating common approaches
So where to begin?
There are a million ways to implement an app. Despite that, let’s take a moment to consider various
approaches and why they may or may not be a good idea.
26
Mobile System Design
Warning: Opinions incoming. It’s okay to disagree! But please humor me and follow along. Seeing
differing approaches can give perspective.
2.2.1 Start with UI?
Would you open your editor and start making the UI and screen right away?
It’s a common way to start, and it’s heavily pushed on us non-assuming developers everywhere.
Tutorials, blogs, online videos, they all show how “easy” it is to make the most exciting (toy) apps.
Which is great if you want to get started on an idea, or to learn, or to get excited about making something.
For your own projects, I definitely encourage you to start with UI. It’s a lot of fun.
In a larger real-world app, however, the rules are a bit different.
When starting with UI, the pitfall is that we avoid considering multiple angles. We’ll “just start building”
without thinking about most of the required parts.
Eventually, you’ll be painting yourself in a corner. You’d discover hidden features and requirements
too late; Like building the walls and then realizing afterwards that the new homeowner would like
some more windows.
By not planning, you’d risk slapping code on much later in a quick and dirty way, adding tech debt in
an early phase.
Another pitfall is that by starting with UI, you might be pulled into discussions with teammates why
SwiftUI is better for this than UIKit, or whether to use Jetpack Compose vs XML.
Just because we receive UI doesn’t mean we have to start there.
Let’s take a step back. We just established that there are already a lot of unknowns.
If we start with UI, we’d be thinking too locally. What I want to emphasize is to see beyond the UI and
think in the bigger picture. Try to uncover all systems hiding in plain sight that are working together to
make this app work.
2.2.2 A data-focused approach?
Maybe you’d start programming but focus on the data? You might start thinking of all the information
that is required, stored, and passed around.
It’s a good idea to think about what data is needed. It will make you ask good questions. Such as
which fields are optional, which determines whether some UI components are hidden or which screen
variations we need.
27
Mobile System Design
Thinking about data will also make you think about the foundations required to build a feature. Thinking
about data will also make you think about persistence, networking, and caching.
These are all important topics in a job interview and building features.
There is a pitfall: Don’t get carried away into creating a perfect working application with the perfect
names, types, structs and fancy testability.
Let the data guide you to think about what needs to be stored, passed, and presented and use that as
a starting point.
2.2.3 Creating an app-skeleton or flow-skeleton?
Assuming there is no app yet, would you be making the entire app skeleton first? Such as settings up
the entry point (e.g. the Main class or AppDelegate), navigation bars, tab bars, maybe add placeholder
UI with empty screens?
Without focusing too much on UI or features, having some sort of skeleton app is a productive way to
sketch out how everything will fit together. At this stage, it’s cheap to add, delete, and update screens
and flows.
Using placeholder screens, you get a good impression of the feel of the program. Just make sure you
don’t get lost in the details yet.
Placeholder screens are a great idea when receiving a flow. However, in our scenario, we can focus on
a single screen.
2.2.4 Starting by making components or features?
Would you focus on making independent components? Such as a scheduler, todo list, and messaging
service? Or maybe UI Components? Such as buttons and other views.
This can be a good idea, but we still have to think about how it all comes together as a whole. At this
stage we don’t know all the requirements yet.
I would avoid focusing too much on single components. Otherwise, we risk getting carried away by
making the perfect views or nicest todo list (we wouldn’t be the first developer to get carried away).
2.2.5 Drawing a diagram?
Would you draw a diagram of this feature and see how everything is connected? Maybe to help decide
what to build first?
28
Mobile System Design
Sketching out a diagram encourages thinking which bits and pieces you’re going to need.
It’s not a mandatory step, but it definitely helps you think about the whole system.
In a real-world application, that’s a great idea. During a System Design interview, coming up with a
diagram is also how you’d be communicating your ideas to the interviewers.
In a team-setting, I strongly recommend drawing some sort of diagram to make sure everybody agrees.
In this book, that is also what we’ll do. We’ll use the diagram as a vehicle to make decisions at every
step.
2.2.6 Decide on an architecture?
Would you perhaps start with an architecture in mind, thinking of maybe using a reactive approach,
or decide between MVVM (Model-View ViewModel), MVC (Model View Controller), or MVP (Model View
Presenter)?
NOTE: Often when app engineers talk about architecture, they are referring to the part that glues the
business code to the UI code. Not the entire, global, architecture of an app.
Here is my advice: Stop thinking about architectures, we haven’t even written a single line of code
yet. Architecture is for much later, if at all. Because maybe all we need are a few minor components.
So far, we’ve only seen one screen. So if someone blurts out “This is a great use-case for reactive
programming!”. Then it signals they haven’t really thought about the problem deeply enough yet. This
is a red flag that someone is too married to “the one” architecture that they’re comfortable with.
Picking an architecture would be a next step, but not the first. We have to consider an architecture that
works best for this app, not what we are comfortable with.
You might catch yourself leaning towards a single architecture regardless of what you have to make. In
that case, try to check in with your biases and experiment with other architectures and programming
languages to expand your horizons.
There is no silver-bullet single architecture, only trends and best practices.
In our case, deciding on an architecture is a bit too early stage. We’ll get there if needed. For now, let’s
agree to let our architecture grow organically the more we understand the problem.
Maybe, for instance, we need to make local architectures that differ for each domain. For example, maybe we want to make a specific declarative architecture to deal with the Calendar part and
scheduling events. Maybe a coworker wants to use a different mini-architecture for offline-storage
functionality.
29
Mobile System Design
Then maybe these mini-architectures will grow together in one large overseeing architecture in the
app itself. Maybe it will be complicated to tie it all together, and maybe it won’t, let’s park that decision
for later.
2.2.7 A recommended approach
We’ve gone over the pros and cons of some common approaches. I’m sure there are more approaches
you may prefer.
There is not one perfect way to attack this “problem”. There are plenty of approaches to take. Many are
valid, some are problematic.
The bigger takeaway here is to try not to get lost by details too much. We’re in the sketching-phase,
we need a general outline of what we need and we need to uncover hidden requirements and we are
dealing with known unknowns and unknowns unknowns right now.
In terms of UI, we wouldn’t start with fancy drop shadows and animations before we even have a
functional screen, right? No it’s better to get some parts working first before we make it look pretty.
So let’s forget architectures and discussions about SwiftUI vs UIKit or Jetpack compose vs XML, forget
thinking about putting data in controllers vs viewmodels.
Let us not argue about why viewmodels do (not) belong in declarative UI. We can defer those discussions
until later. This will give us a better understanding of what we’re going to build.
No matter the approach you prefer, I hope we can at least agree on one thing:
The best decision at the first stage is to understand the problem better.
Understanding the problem better will give us a better starting-point, because it will help us uncover
wrong assumptions and missing features.
So that’s what we’re going to do right now. Let’s begin by drawing a diagram.
2.3 Sketching out a landscape
We will create a landscape, which is a collection of domains and components that will be required
to make our feature work. We can think of this as our architecture, but instead of “choosing an
architecture”, we will let it organically grow and evolve.
First, let’s get the obvious features out of the way. This will get them out of our heads, and then we can
focus on the not-so-obvious secondary requirements.
30
Mobile System Design
We see that the screen has some functionality staring in our face since it’s UI and thus customer-facing.
There is an avatar, some sort of Todo list, a way to open a 1on1 call. But, we don’t know the details
yet.
Let’s note down the UI-based features in plain text, so that we can turn these into a graph after for a
better visual representation.
Featureset:
• Some sort of TODO List
– TODO’s are recurring (by week or day, maybe others?)
– TODO’s can open a detail (details unknown)
• A Tutor profile
– Has avatar and name
– A tutor has some sort of dismissible callout
– Some sort of messaging feature to contact a tutor
• Some sort of calendar functionality
– There is a summary of planned 1on1 calendar-events
– Ability to reschedule
– Ability to join calls
• Swapping between courses: Up top, we see that the user can open a different course with a
different tutor.
We are sketching here. Hence, we call the feature “some sort of...” since we don’t know yet exactly
how it will work.
For example, we see a calendar event for a meeting. Should we call it a Meeting or 1on1? Or maybe
CalendarEvent or Scheduler? Let’s go with Calendar since it’s an umbrella term for scheduling
events, rescheduling, linking to moments in time, etc. We can always rename it if needed. Then
Calendar can help us join an event or call, and maybe it can help reschedule with some sort of
Scheduler.
Let’s turn the aforementioned feature set into a diagram (we’ll deal with Swapping tutors in a bit).
31
Mobile System Design
NOTE: It’s okay and expected to start naïvely. There is no pressure to figure it out perfectly in a single
attempt. We’re brainstorming.
Notice how Tutor, Calendar, and TODOList have a regular outline. This means that they are decomposed, they are broken down into child nodes. If we wanted, we could start implementing them
roughly, despite their child-nodes not being figured out.
Then, one layer below, we mark features with a dashed outline; Dashed means that components aren’t
either fully figured out yet, or they are not decomposed yet. It’s a way to signal that they aren’t “done”
thinking about.
For instance, the TODOList -> Entries connection isn’t figured out, since its requirements are vague as
of now. Are they stored locally? Do they need an API call? We have too many unknowns to mark them
as resolved with a regular outline.
Going beyond that, Entries link to Details, what does Details show? We don’t know yet, it’s not
important for this screen. All we need to know is that it does “something”, so we mark it as dashed as
well.
2.3.1 Everything is connected to a course
If we zoom out a little more, we can say that all these features are connected to a course in a way.
In other words, a course owns, Tutor, Calendar, and TODOList. On top of that, there is also an
option to swap courses.
Let’s update the graph to represent that these components all belong to a Course.
32
Mobile System Design
Looking at Swap Course we can see it’s a child node of Course, we haven’t really figured swapping
courses out yet so we leave it dashed.
Consider swapping courses a low priority feature, because why worry about supporting multiple
courses or tutors while we haven’t even gotten a single course to work?
We do know we need it, so we add it to the diagram and mark it as dashed. After we talk to the designer
we may learn more about this feature.
NOTE: Remember, we are sketching and iterating and learning about the requirements. It’s okay if it’s
"good enough" for now. Try to turn off that perfectionism internal dialogue (if you know how, please
share your secret).
2.3.2 How far do we decompose?
If we were to keep zooming out, we’d see the entire app connected as one giant graph of components
depicted as nodes.
You may wonder how far to keep decomposing.
A rule of thumb is: Decompose until you feel you understand the problem well enough to start the
implementation.
There is no need to keep decomposing until we dissected components into ones and zeros.
At this stage, the art of a technical design is jumping from feature to feature, from requirement to
requirement, and figuring out which components to make. It’s important to keep it high-level, so we
don’t lose time focusing on details.
Make it clear what to focus on and acknowledge that there are unknown components that you need to
resolve later.
33
Mobile System Design
Before we start implementing this feature, let’s continue figuring out requirements and components
until we feel confident enough to start.
2.4 Uncovering secondary requirements
We now have an approach; We are creating a landscape graph where we decide what to make and
what to figure out.
We focused on “obvious” UI features that were staring us in the face. However, UI is only part of the
picture.
We could start implementing now, but our job right now is to uncover hidden requirements, find
edge-cases, not just what we see in the UI.
If we don’t make a plan, and “just start building” it will hurt us later. Such as having to redo features
because we uncovered an important detail nobody thought of yet. Or worse: We’d focus on features
that aren’t needed at all in hindsight, throwing away weeks of work.
Let’s focus on the not-so-obvious parts and move on to secondary requirements.
Don’t open Xcode or Android Studio or whichever IDE you prefer. Don’t start programming. I know, I
know, it’s where the fun happens. We’ll get to that in the next chapter.
Unfortunately, taking a moment and talking to people has priority now.
NOTE: “Fun” fact: Did you know that the more you get promoted, the less time you spend programming
and more time talking to people in meetings?
We should take some time and uncover unknown requirements. We do this in a few ways:
1. Think about and figure out all components (not just UI) that are needed to build the feature. It
sounds obvious, but many can’t resist programming right away.
2. Figuring out missing functionalities or requirements; Such as by asking the right questions to
teammates with various roles and disciplines.
3. Trying to challenge the design, and think of ways that the design would break, uncovering new
requirements.
Chances are, we’d uncover details or missing elements that others haven’t even thought of yet.
There may be details lurking that can be important to know before we even write class Course.
By getting a full picture of the problem-space, we might even make sure that our team iterates over
features before we start writing code. It might surprise you how often you can uncover an important
“Oh we didn’t think of that” which affects both UI and backend.
34
Mobile System Design
NOTE: During System Design interviews, you can’t go back and forth with a designer. But you can
communicate the unknown details and missing information. This shows the interviewers that you’re
understanding the problem on a deeper level, and are considering edge-cases.
2.5 Working with Designers; Getting secondary features
Let’s approach talking to fictional designers, so that we can uncover more requirements, and as a
result, come up with a better design.
The goal of talking to designers is threefold:
• First, to make sure we understand the problem better.
• Second, we want to make sure they understand the problem better, too. By offering some
technical perspective.
• Third, we want to challenge the design itself. Not to annoy the designer, but to make sure we find
edge-cases, (de)prioritize UI components, and uncover situations that nobody has considered
yet.
With some good input, you can trigger a small iteration of the design before you even begin.
A productive conversation should result in an improved design and a better understanding of
the feature in the team.
NOTE: Keep in mind that the designer is your ally! You are improving the product together. You’re not
here to be their devil’s advocate and critique their work.
While you’re at it, inform designers about the way you work. Tell them that the UI — although functional
— probably won’t look nice in the beginning. Remove their worries that you will not finish the details
later. Fancy UI details might not be the highest priority for us, but it often is for designers.
Working closely with the designer has a lot of perks, one of them is minimizing the communication
gap. The worst thing is to not communicate together, wait for weeks, and have the designer give you
the final-final3-final-reallyfinal.sketch design file for you to implement.
It’s vital to work closely together to create a better design and plan.
Let’s go over some talking points so we can learn more about the feature and update the graph where
needed.
35
Mobile System Design
2.5.1 Whether or not a design is the "law"
You may wonder if a design is “the law” and should be 100% followed, or if it’s more of a communication
tool, ready to be interpreted.
The designer could deliver everything with a low fidelity (low detailed) design, or even in plain English
“Make a screen with an avatar top-left, and a todo-list at the bottom, and . . . ”
This approach is too hard to align on. Maybe that’s how we will work in the future with AI generating
apps.
More commonly, we receive a design that brings us closer to the final product. But it isn’t the final
product – we aren’t shipping images to customers after all.
We can’t assume a design captures all variations, even when a design program supports code.
When you multiply all supported device sizes times platforms (e.g. phone and tablet) times dark mode
times light mode, times all font sizes, the design file would be huge. It doesn’t make sense to design
everything upfront.
Even if a designer gives us those variations, the design wouldn’t define everything still.
A design doesn’t convey things such as animations, all language variations, or how the screen would
look with a slow network connection, or what the experience is like when the customer has to retry a
failing submit form.
We can get close, but the design is still an approximation of the final product.
2.5.2 What is “pixel perfect”, really?
When we say we’re delivering something “pixel perfect”, we usually refer to making the UI exactly like
the design where we can. We will pick the exact colors and border widths, and we will position the
elements for a specific screen. We might overlay the design over the app and pixel peep to make sure
everything looks the same.
However, there is room for variables, where each screen will look different because of its content and
environment. The app might have dark mode enabled, or use a Right-To-Left language. Or use larger
font sizes for accessibility. With little effort, we already deviate from these undesigned variations.
That means that “pixel perfect” is often an approximation. Even the way apps render shadows differ
per platform, so that’s not “pixel-perfect”, that’s “as close as we can get that makes the designer sign
off on it”.
The design is a communication tool depicting the final implementation. It’s a plan of what to
make.
36
Mobile System Design
At one point we have to decide “This design is enough for us to agree on things and get going”. It can’t
be the absolute law for everything.
For a smoother process, use the design as a starting point. For all deviations, be sure to include the
designer during development while iterating. Make sure they agree on decisions, such as supporting
large fonts, or small fonts, animations, and anything else that might deviate.
2.5.3 Designs often encompass best-case scenarios
A designer will usually deliver neatly filled screens. They’ll pick a nice stock photo to fill the necessary
images and they’ll make sure all text fields will contain a full ‘lorem ipsum’ text. It’s the perfect-looking
content for the perfect screen.
But then real life comes into play. Real-life data hits different. It’s random, dirty, ugly, and all over the
place and that may not always fit the intended designs.
Once you let people upload their own pictures and add their own information, the app won’t look like
the design. We can’t guarantee that the — once aesthetically pleasing — screen won’t look as appealing
in practice.
An avatar might be missing, or perhaps it’s too low-resolution. The descriptions might be way too long
and half the fields might be empty. Not every user takes great care in filling in their details.
If texts are missing, is that okay? Here, we need to make sure to check with the designer that it’s a
viable option. If not, there’s a strong chance that the UI might break with imbalanced content.
As a rule of thumb: Ask for a worst-case scenario design with poor content and see if the design
still holds. Even with the worst content imaginable, the design should not break.
As a developer, thinking of a particular design isn’t necessarily your job. However, you are working on
this project together and poor content is a truth we can’t avoid. So, it’s better to find any issues now
preemptively versus facing potential complications down the line.
2.5.4 Not everything has equal priority
A surefire way to deliver less efficiently is to accept anything in a design as absolute, uncontested truth
and implement it as if to blindly follow orders.
But not all ideas are weighted equally. A design is not set in stone; It’s a preliminary plan or proposal of
what to make. Like all designs, it is subject to iteration. With that in mind, you’ll be the one making it
come to life – albeit behind a pane of glass.
37
Mobile System Design
When receiving a design, don’t assume all components are equal in terms of priority and magnitude.
It may sound counterintuitive, but finding ways to not build features can ultimately lead to better
outcomes for everyone, including the designer. Getting a team to agree to prioritize features will allow
you to ship more quickly. As a result, the team — including the designer — will receive insights from
customers and other stakeholders within a shorter time frame.
You will also be able to focus on the core features that we absolutely need to ship, versus those that
are more accessory.
Taking a proactive approach to learning at the early stages of the development cycle allows us to
change course more quickly as needed. It also helps us avoid the potential risk or throwing away
valuable time building features that customers didn’t actually want.
For example, in our case, the assumption right now is that people can have multiple courses (tutors) at
the same time. In reality, usually someone is learning one major skill at a time (e.g. learning Spanish).
But it’s rare that someone wants to learn Spanish, German, English, and Korean simultaneously.
In the design we see someone has two courses (Guitar, and Spanish), two courses are more than
enough for most users.
Even if there is a strong disagreement with a designer about supporting only one course; Practically
speaking, we need to support a single course before we can even build support for multiple courses,
anyway.
So the question is less about “will we support multiple courses”, and more about “will we ship with
support for one course first?”. It’s about prioritization.
Don’t underestimate the difference in time investment. A designer might add a multi-course feature
within a short time, but implementation may take days or even weeks.
When receiving designs, it’s the perfect moment to stop and think mindfully and critically about what’s
really important for the user. Re-evaluating priorities together with the designer mutually benefits
everyone in the long run.
2.5.5 Verify the existence of pre-existing components
After receiving designs, be sure to run the components by other client engineers. Be sure to check what
they already know exists in order to avoid reinventing the wheel.
It may seem completely obvious, but yet doesn’t always happen in reality; A single question in a Slack
channel, such as “Does a component like this already exist?” can save you days of unnecessary work.
38
Mobile System Design
Conversely, maybe there already is a component that you potentially could use, but it may be just
slightly different from what the designer gives us. This is a telltale sign that there is some plausible
misalignment between the client libraries and designs. If so, this might be a good time persuading the
designer to use what we have.
If there is a sound reason to make an adjustment to the existing component, or a new component
altogether, then so be it. However, a little pushback for shipping more quickly can be a tremendous
timesaver, yielding other indirect benefits. These include, but are not limited to, avoiding having to
maintain more duplicate components, which is a hidden time sink that compromises productivity.
2.5.6 Ask general UI questions
These are a repertoire of general questions you can ask which effectively apply to most UI-based
projects.
Try to think of things that the designer hasn’t thought of yet. This helps us find requirements that we
could miss.
Some examples are:
• What if there is more information than fits the screen? Will you make the screen scrollable? Or
will you resize elements?
• What does the screen look like when it’s empty? (In our case, what if the tutor has not filled any
information for us yet?)
• Have you thought of large font sizes on small devices? Will the screen break?
• What would the screen look like with long labels and/or verbose languages? E.g. German needs
longer text. Will that fit?
• Have you thought of tablets? Will the screen look too empty?
• Do we support landscape mode?
• How will we treat errors? Just throw an alert or something nicer and more inline?
– What about partial errors? E.g. the tutor data is loaded, but the TODO List can’t be loaded.
Will you show partial errors, or will you throw an error for the entire screen?
• Do we support dark/night mode?
39
Mobile System Design
2.5.7 Ask functionality-related questions
After you’ve exhausted your general UI questions, you can get even more information by trying to find
edge-cases that might break the screen.
Try to come up with ways the feature might work different from intended. Like the general UI questions,
asking these questions will help us find potential problems early on.
These questions would be feature-specific, such as
• When a user completes a TODO item, do we assume it sends that message to the server right
away? If so, what if that network call fails? A giant alert might be too much. Can we use some
sort of notification or toast? And will the TODO reset exactly?
– If the network call fails while the user backgrounds the app, will we let it fail silently or will
the app send a local notification?
• Do all TODO items have a detailed screen? Or is that optional?
• Are all TODO’s always scheduled? Or can they be unscheduled TODO’s without a deadline?
• What if the tutor hasn’t made a plan (yet)? What will the student see?
• What if you haven’t selected a tutor yet, what would the screen look like?
• What happens if a tutor has an extremely long callout message? Should it be cut off, or expand
on press? And at how many lines?
• Are the daily TODO’s automatically reset? And when would that happen, maybe at midnight at
their local time zone? Or after X time, such as after 24 hours?
• If I press “reset all”, would the user get a warning? Some sort of alert perhaps?
• If user dismisses the tutor’s callout, will they lose it forever? Or can they get it back?
• What happens if a user joins the Calendar event before it’s ready? Can they join an empty meeting?
Or do they get an error?
• Will Calendar calls be a link or handled in app?
• Will messages be a link or handled in app?
By imagining you’re using the app, you can come up with practical questions to get you and the designer
thinking more in-depth about the functionality.
Perhaps you’ll get a couple “I didn’t think of that actually”. Which is good, because that’s exactly what
you want to hear now as opposed to later after most of it is implemented.
40
Mobile System Design
It also makes you think of a problem more deeply, thus giving you a better understanding of what to
build. As a result, you will get more “ownership” of the problem. In essence, bringing you closer to
being the expert on this screen.
During a job interview, it’s important to show all the missing details that you’re thinking of. It’s a way to
impress interviewers by showing you think of a lot of non-obvious parts and secondary requirements.
2.5.8 Talk about error handling
Error handling is often a low priority for designers and developers alike. Within good reason, it’s more
important to get a feature working first.
But a real problem is that people don’t often think about the errors in the initial stage. That’s problematic since it can negatively impact design if it’s added as an after-thought.
If you don’t think about errors early on, chances are, you’ll deliver an app with a giant alert obstructing
the view, stating "Something went wrong". Which is a bad user experience and sort of a "last resort"
error.
Push towards using errors that don’t block the UI, such as toasts (little notifications) or a view having a
special inline message. Avoiding blocking the UI where possible for a better user experience.
You may need to work with the designer to help them understand where and when errors can occur.
Maybe your UI only has a single point of failure, or maybe errors can be displayed inline per individual
component that loads.
2.5.9 Talk about time-investments and start thinking in a less binary fashion
In some cases, maybe you’re tempted to avoid implementing specific parts of a design. For instance,
the designer might have a custom navigation bar that’s quite difficult to get just right, and it may be
even harder to maintain.
It’s easy to fall into the trap of thinking in binary, black and white terms such as “worth implementing”
vs “not worth implementing”.
For instance, you might be great at devising arguments which demonstrate why a custom navigation
bar is a bad idea. Despite the fact that, on paper, the points you’ve made appear objectively correct –
making a custom navigation bar is almost always a maintenance headache – To others, you may be
unwittingly labeled as a “rigid” developer.
However, don’t jump the gun and reactively say “No.” Instead, shift the conversation towards focusing
on priorities and timelines. Instead of saying, “I wouldn’t do that”, you could alternatively say: “This
will be x weeks more work for us to do.”
41
Mobile System Design
From that point on, as a team, you can then decide if the custom navigation bar is worth all that extra
time. Because, maybe it is worth it to the company to have a custom styling to maintain a central
theme in the application.
The point is to quantify the consequences and make decisions and alternatives more tangible.
2.5.10 Giving feedback to the designer
Not everyone can take feedback like a champ. I’d argue it’s difficult for most people. As developers, we
are more likely to be battle-hardened by having our work critiqued day after day, multiple times a day,
but it always stings.
However, for designers, the process is different. They design for days or weeks on end, maybe even wait
a week to finally get approval from the “design systems council”, then they iterate some more, and then
finally, after hard work, they will showcase their work to the rest; After which the entire department will
have opinions about their work. “I wish we used the colors from before” or “I wish the border wasn’t so
strong” or “Why doesn’t Google use drop shadows yet we do?”
It stinks for designers to be critiqued by experts and non-experts alike. Everyone has an opinion on UI,
yet not everyone has an opinion about code.
No matter how often people say they don’t get attached to their work, be sure to not only critique UI but
balance feedback with positive remarks as well, and try to keep it objective.
2.5.11 Updating the landscape
After talking to the designer, we learned a bunch of things for our project, such as
• The app is for phones only, not tablets.
• Dark mode is needed but we’ll do that in a later stage. (We mark it as lower priority)
• The weekly and daily schedule auto-resets. (We don’t know yet if that happens on app or backend
and how often)
• Scheduler opens a picker (There is no design yet)
• After proposing a schedule, the other party must agree. If the other party doesn’t and a schedule
has passed, the proposed schedule is deleted.
• We agree that we won’t support multiple courses for the first version.
Talking to a designer will uncover a lot of details that won’t make it into our landscape graph. But for
our purpose, we will focus on a few things that stand out which we’ll cover next.
42
Mobile System Design
2.5.12 A fast app is key
By thinking about the problem more and talking to hypothetical designers, we uncovered more important pieces of the puzzle.
For instance, the designer shares that users want to hop in quickly, check off TODO’s, and leave the
app again.
We realize that if the app would be fully dependent on the server it would mean the user would open
the app, log in, fetch data, hopefully not get an error, and then finally see the TODO List, check off a
single item and leave the app, hoping the network call succeeds before the app backgrounds or is
killed.
We can translate that requirement into a “thing” we need to make, or a Non-Functional Requirement.
In our case, that’s offline-mode support in the shape of a persistent store. So that the course and its
TODO items are always available, making it less dependent on a stable network connection.
We don’t know yet whether this store is going to use MySQL, NoSQL, or an insecure text file.
The designer or customer doesn’t care about the details long as it works. It’s our job to care about how
it will work, but not at this stage. Because at this stage, we need a good idea of what we’re about to
make, but we don’t need all details yet.
Let’s update our landscape and add some sort of persistent store component that we need to offer for
Course. We’ll call it Store.
Because we don’t know the details yet, we mark it as dashed.
2.5.13 Scheduler
We also learned we need some way to not only reschedule, but also cancel calendar events.
43
Mobile System Design
The designer confirms that cancelation is out of the scope for this screen, since rescheduling or canceling triggers a new flow. This means we can write these features down, but we don’t have to focus on it
too much. We mark them as dashed in our landscape graph.
2.5.14 Deep Linking
We also got confirmation that features such as messaging a tutor, or opening Calendar calls with the
tutor, will happen in other parts of the application. For now, we can assume these will be deep links,
so we add that to the graph.
Again, we mark these as dashed since we know they are needed at some point, we just don’t need to
figure them out at this stage.
2.6 Aligning with backend engineers
Thanks to talking to the designer, we have become more aware of secondary (hidden) features. We can
follow a similar process by talking to the backend engineer.
44
Mobile System Design
This way, we hope to uncover more secondary requirements and important details related to data-flow
between backend and client.
In real-life, the backend engineer can already link to documentation for you. In our case, there are still
bits and pieces to fill specific to this screen.
In this section, we’ll go over some tips to make the integration process smoother. At the end of this
chapter, we’ll update the landscape again with new feature-specific requirements.
2.6.1 Align on about User sessions, environments, tokens, and timeouts
Once you start talking to a backend engineer about which environment to talk to, it will be hard to
avoid mentioning about login tokens and user sessions.
Be sure to cover that, get the necessary information to make backend calls as soon as possible. Because
you’ll learn right here and now of any problems that you will run into later.
For example, maybe there isn’t a staging environment, or maybe that user account they made for you
doesn’t have the proper access that you need.
Or maybe you need to request user-permissions and the team approving it is slow. It’s better to know
these limitations earlier than later.
Ideally, you can already experiment by making an API call from the command line (such as by using
the cURL command line tool) and try to make it work. Then, if you have trouble integrating an API call
from the app client, you can verify if the cURL call will work. Saving you time since you can rule out if
the problem is on the backend or not.
Then there are more feature-specific features. Such as obtaining a login token, how can we get one
without a login screen (it’s not built yet).
Think about timeouts. Let’s say someone tries to complete a TODO item after being logged in too long.
Does that trigger a timeout? If so, what kind of errors will we get on timeouts?
If timeouts are a requirement, then most likely all API calls must work with this mechanic and the app
needs to respond to it, which can get complicated. Something to keep in mind.
2.6.2 Align on consolidating network calls
Regarding our feature, check if you want a single API call to populate the screen, or if you need to make
multiple calls to get a populated screen. Multiple calls is easier for the backend, but might complicate
things for you since you have to combine them.
45
Mobile System Design
An argument to ask for a consolidated network call (assuming GraphQL isn’t an option) is to think
about multi-platform implications.
Let’s assume that you require multiple API calls to fill a screen you’re working on. Then the iOS app
needs to make some sort of logic to accommodate for this. This may be the same time investment for
backend, so it’s not enough to convince a backend engineer to take on this extra work. If you ask, you
might be told that it’s not an option and endpoints need to be pure and semantic. (Sometimes people
just don’t want or have time to do extra work, who knew?)
If bribing the backend developer with coffee doesn’t work, consider shifting the conversation towards
time investments.
For instance, let’s say we are also making – or planning to make – an Android app. It’s the same problem
again, except Android engineers now also need to invest time for this. Now let’s say we also make a
web client. It’s the same problem again.
If you’re going to make the same investment multiple times, it’s not that hard to convince a product
owner to push for making this investment once in the backend, as opposed to thrice on clients.
2.6.3 Be on the same page with errors
Next, consider how errors are handled. Will there be a single endpoint but with granular errors shown
for each data model? Or will you get one generic "something went wrong" error?
A common pitfall that sometimes gets overlooked, is ignoring the fact that backend-errors are localized
on the client. It may sound obvious, but experience shows that it can easily be forgotten when it comes
to handling errors.
For instance, sometimes a backend API will give plain English errors, such as “Could not load the user’s
TODOList”. Which is fine for us to help debug issues. However, this should not be customer-facing
text.
We need error codes. Because, as a client engineer we can localize them. As an example, we can turn
“error code 11” into “The TODOList couldn’t be loaded” for English, and “De TODO lijst kan niet worden
opgehaald” in Dutch.
As a result, you need to align with backend engineers on the meaning behind error codes. You can
propose a list where each code represents an error and their app translations.
2.6.4 It’s okay to deviate from backend custom error codes
A backend may already have error codes, code 11 meaning “The TODOList couldn’t be loaded” or code
12 meaning “The user session has timed out”. But you don’t have to align them fully with backend,
46
Mobile System Design
because on top of backend error codes, you will also have client error codes related to the backend
call.
So keeping your error-codes in sync isn’t always possible.
For instance, maybe the backend will not give you an error, but then you try to parse the data and that
gives a client-specific error. Or, perhaps, the client will have a network timeout which isn’t necessarily
backend-specific.
So you will still need error-codes on top of the backend ones that are not shared by the backend.
2.6.5 You might be the backend guinea-pig
If you’re making the first client talking to the backend then the process is likely much slower, and you
have to take that into account.
In that scenario, you implicitly take on a second role, which is to be the backender’s betatester or
guinea-pig. You will be the first “customer” that will make use of a new untested, beta version of the
backend.
For instance, when implementing API calls, you may receive an error with a vague message. Or maybe
you’re treated with cryptic 500 errors. Either way, you will depend a lot more on a backend developer
to fix issues, before you can move forward. As opposed to integrating the second or third client where
a lot of issues have already been ironed out.
Often when you hit an issue during integration it will be unclear where a bug is; Is it a client bug or
backend bug? Now it probably takes at least two people to debug it. You’d have to look at both the
backend and client to find the issue, which is quite a large scope of “The bug should be anywhere
around these parts.”
For this, cURL is a good tool to verify if the network call works well, even without a mobile client. So
you can narrow the problem-scope and establish (or rule out) there is a client problem.
Alternatively, let’s assume there already is a working Android app and now you’re making the iOS app.
If your iOS app has trouble communicating with backend, the scope of the bug is much narrower. You
know it’s most likely a bug on the iOS side since the Android app is already working.
When planning, take it into account whether you’re the first integrator; If you are the first integrator, as
a rule of thumb, you should double or triple the time it takes to integrate something.
2.6.6 Read code from other client implementations
If possible, you can considerably speed up your backend-integration by checking out the source from
other clients that already talk to the backend.
47
Mobile System Design
Verify if there already is a Web client or Android app or something else like a headless client (commandline interface only). Inspect how they solved similar problems. A little web debugging goes a long
way.
It’s okay if the programming languages used are unfamiliar to you. As long as you’re able to get the gist
of it, you’ll be fine. There is no need to be afraid to ask for help, most engineers are excited to share
how they solved a similar problem.
2.6.7 Consider push notifications
Although not part of the UI, push notifications are part of the UX (user experience).
We might need to support certain messages, such as when a tutor left you a new message, or when
you received a new schedule. Either way, there should be some way to inform the student.
To make push notifications work, we also need to think about registering a device for backend.
Push messages aren’t always localized in each app. If a users’ app is set to French, we should avoid
sending English push messages. It’s not a great experience, so you need some sort of mechanic to
submit a device’s language to the backend.
For push notifications to be localized, we need to send a user’s language settings to the backend.
Although this is not specific to our feature, this is relevant in most apps, so worth to keep in mind.
2.6.8 Feature-specific questions
Next, let’s think about feature-specific questions that we can ask to uncover secondary requirements,
such as:
• Which fields are optional?
• Will I get all data at once or do I need to assemble it?
• How will the app submit the TODO items?
• Assuming some TODO items are reset every 24 hours, how do we communicate this? Does the
backend know which timezone we’re in, how?
– Maybe the client needs to send their current timezone?
• What format will we use? JSON, Graphql, Protocol buffers, something else?
• Which data is something that customers fetch every time? If so, maybe we can cache some
locally?
48
Mobile System Design
Depending on the context you can get as detailed as you want. Talk about security, caching, and so
forth. But note that at this stage, we haven’t implemented anything yet, so it’s good to get a big picture,
but don’t get lost in the details at this stage.
2.6.9 Updating the landscape with backend requirements
We learn that a few things from this screen will be fetched; The TODO elements, the tutor’s profile (with
its callout message), and the schedule information. All these need some form of network (API) calls.
Even though we know we need an API for the network calls, at this stage we don’t have to worry about
the implementation details yet. So we can add it to the landscape graph and mark it as dashed (to be
figured out later).
2.7 You are the link between backend and design
Even if you are on the same team that regularly has standups. As a client engineer, misalignment
details and misunderstandings will come out during your work. Because the designer(s) and backend
engineer(s) might share only general information during team-meetings, or they may work in silos, or
they might be working out the details in their own domains and only mention those to you. Now you’ll
be connecting the backend APIs to the UI, making you the missing link between them.
There will probably be some misalignments because of this, and it’s best to assume it’s your job to
catch these misalignments early on a detailed level.
Try to think of all data that needs to be communicated and how that reflects in the UI. Try to find those
edge-cases.
Optionality is a big one. It’s a small thing to say “A name may or may not be filled in”, but if the designer
assumes data is always present then it might break the UI.
49
Mobile System Design
Or vice versa, a backender might make wrong assumptions about the data. For instance, they may think
a tutor will always have a full name, but it might not match the customer’s and designer’s expectations.
Maybe some tutors use aliases and the backend engineer wasn’t aware. Now they have to update the
codebase, APIs will return different values, documentation needs to be updated, and so on.
To emphasize: you are the link between backend and design and will be the one uncovering wrong
assumptions. Find misalignment now at the early phase, as opposed to later during implementation.
2.8 Closing thoughts
We’ve only received one screen, yet there is already a ton to consider. By taking some extra time to
think we have a more rounded idea of what to make, resulting in a landscape graph which we can use
to express the components and domains of our architecture.
This approach might be overkill for a personal hobby project. But I hope you have gotten some useful
points out of it that apply in a regular work setting.
Even though we didn’t start programming right away, in the end it’s about saving time and working on
the right things. It’s better to save time upfront than implement code and having to refactor most of it
because we didn’t plan accordingly.
In the next chapter we will actually start implementation, which is where the real fun begins.
2.9 The takeaways
In this chapter, we covered:
• Try to understand the problem better before starting to code.
• Drawing a graph with limited information helps design a component landscape.
• That it’s okay to not have all the answers at this stage.
• Talk in terms of time investments, not in what you should, or shouldn’t, implement.
• If the feature already exists on another platform, talk to developers of that platform and ask to
inspect their code.
• Aligning about optional data impacts both the data model and the design.
• Check if timeouts impact your feature. Such as staying logged-in too long.
• Ask what errors you can expect.
• Check if partial errors are an option, such as partially loaded data. This impacts both network
calls and design.
50
Mobile System Design
• You are the link between backend and design. Save lost time by finding misalignments during
briefings.
Design
• A design is a communication tool, not a representation of the final product.
• Try to uncover hidden requirements and functionalities that are not clear from the design.
• Verify if there are pre-existing components that you can use.
• Communicate with a designer on what to prioritize and what can be skipped.
• A design usually encompasses a best-case scenario. Be sure to ask for a design with real-world
data.
• Find hidden requirements and edge cases by trying to break the designs.
• Always be kind and gentle when critiquing designs.
Backend
• Align on user sessions and user tokens
• Check if non-UI features impact your feature. Such as notifications, that is part of the UX, but not
UI, and is aligned with backend engineers.
• If you’re the first implementer of a backend API, assume that you’re also going to be its tester.
Adjust your planning for this.
• Steer towards error-codes, not string-based errors supplied by the backend.
• Align on consolidating network calls. Maybe you can make your life easier by receiving a single
network call.
51
3 Holistic-Driven Development; Turning a plan
into code
In this chapter
• Applying a global mindset when developing a program
• Keeping momentum while coding with partial information
• Designing data models
• Designing and connecting component APIs
• Reasoning about placeholders
• Reasoning about what to prioritize at various steps
• Reasoning about async code
In the last chapter, we established the components or domains needed to create a feature. While doing
so, we uncovered and collected enough requirements to feel confident enough to start developing.
In this chapter, it’s time to figure out the parts and how everything works together. That also means we
can finally work on the fun part: programming.
We will work with a holistic mindset to turn these ideas into actual code. Meaning we take a wide
approach to develop our feature.
Working holistically allows us to maintain speed while we figure out the details. It will allow us to, dare
I say it, “stay agile”.
To be more specific, we will design the components with a top-down approach. We’ll connect all parts
together using placeholder implementations.
After we’re happy, we can replace the placeholders with real implementations. Using this approach,
we keep going lower in the stack until we’ve got everything working.
NOTE: A top-down approach of programming is sometimes referred to as “Programming by wishful
thinking”. Coined in the renowned programming book ‘The Structure and Interpretation of Computer
Programs’. The difference in this chapter, is that we’ll also focus on placeholder implementations while
shifting priorities.
53
Mobile System Design
The main threat right now is that we get lost in implementation details. But, since we will design code at
a higher-level, we can easily shuffle things around, rethink component APIs, think about dependencies,
rename things quickly, and so on.
This liberates us from needing to understand or focus on all details at a core level; There is no giant
refactor pending if we change something.
3.1 Be able to handle unknowns
A secondary benefit of this approach is that scary unknowns won’t stop us.
For example, let’s assume you never worked with GraphQL before. Does that worry you if you get told
“Implement GraphQL please”?
It doesn’t matter.
We can move forward with confidence that we can implement it all later. Including dealing with third
party technologies we haven’t even heard of!
Once we are happy with the outlines, we can write the implementation one layer deeper in the abstraction hierarchy. We keep going deeper until we finished the entire implementation, and everything will
magically work. It breaks down enormous tasks into smaller problems that are easier to handle.
Think of it as sketching with pencil and paper. To draw a face, we first draw rough outlines and contours.
If instead we focus right away on the nostrils while ignoring the rest of the entire head, we risk having
to start over since maybe the nose might be a bit crooked once the head is drawn.
In an interview, it will be a similar approach. People will want to see how you think about the interplay
between all systems. They want to see how data flows and how all parts connect together into a
complete system.
System design interviewers don’t care that you forgot to call the resume() method on a network class.
They don’t always mind if a project isn’t compiling.
NOTE: Pro-tip: if you have trouble getting your code to compile during an interview, then say it’s “intentional” so you can focus on more important parts.
3.2 A quick note about interfaces and APIs
APIs and interfaces are an overloaded term. To avoid confusing the terminology about APIs and
interfaces, let’s be clear about what we mean in this chapter.
54
Mobile System Design
In this chapter, when we design components interfaces or APIs, it does not imply that we will make an
interface type. It also does not mean we’re designing the backend API.
When we design component interfaces – or APIs – it implies we are designing the methods and properties of a concrete type, such as a class or struct.
In Swift, to make an interface, we use the protocol keyword, but again, that’s not what we’re doing
here. We can get extremely far with real, concrete, implementations. In fact, once we are done with
this chapter, there won’t be a single protocol, and yet we’ll have a working application.
This chapter will highlight how far you can get without interface types or protocols. We will cover the
role of interfaces, or protocols, in the upcoming chapters.
3.3 The relationship between graph nodes and code
In this chapter, we’ll make an implementation based on the landscape we defined.
Let’s grab the landscape. But notice that it’s a bit overwhelming to have everything laid out at once.
Let’s grey out all the unnecessary parts and focus on what we need to implement to finish the screen
at this phase.
To finish our feature, we keep Course. Below that there is Tutor, Calendar, TODOList, and Store,
and below that we can find API which is in charge of the network calls.
Highlighted are now the domains we will be working with.
55
Mobile System Design
NOTE: Swap course is a feature that we ignore, since it’s a lower priority for us now, as decided in the
previous chapter. Because why spend time on swapping courses if we don’t even have a single course
working yet?
We sketched out the landscape, but we need to decide what each node represents. A class, a domain,
a module perhaps?
Let’s decide that a node is its own domain. For instance, API (highlighted at the bottom) can end up
becoming a giant library that allows us to perform network calls across multiple platforms. It might
deal with caching and maybe offer extensive testing capability.
Or, just maybe, API will be nothing more than a tiny helper function that makes the simplest of network
calls.
Whatever API will grow to be, it will be in its own domain.
Next, let’s take a look at TODOList. It might one day be its own library with various classes and
methods, maybe it will eventually be an open source library to help others! Who knows? (In our case,
we’ll keep it local).
Think of nodes like a namespace, or a module. API offers network functionality, but may use 8 classes
and 20 structs to achieve that. The Tutor domain may consist out of a TutorService that returns
Tutor structs.
The main takeaway here is that each node represents its own domain, but inside of that domain there
could be any number of classes, interfaces, data models, methods, and dirty workarounds.
3.4 The process of holistic driven development
Let’s go over the process that we’ll apply in this chapter. After that, we’ll start implementing. I promise
we’ll write code in this chapter, pinky swear.
First, we have to decide What is most important to focus on right now?
56
Mobile System Design
We established that these domains marked in black are required to make the screen work.
Where do we begin? I propose we begin at the top. The reason for that is things higher up have a higher
priority by default.
In other words, we can consider Course the most important part that we have to make first.
For example, later on we’ll have to make some UI that uses Course. This means we’ll end up with a UI
domain up top. UI will only need to get the information from Course. We can state that if we give a
Course to UI, then how everything works underwater doesn’t matter from the UI perspective.
It shouldn’t matter to UI whether Course uses one giant imperative god-class or 400 mini functions.
All that the UI says to Course is “Give me a course” and “I am checking off a TODOItem”.
We are delivering functioning UI, and since Course exists to serve the UI, we consider Course the
highest priority inside our domains.
We’ll start by defining the types needed inside the Course domain, and then we’ll work our way down
the landscape graph.
After defining Course, we go one layer deeper and we repeat the process again. We’ll define the minimally required code to give Course some data from the Tutor, Calendar, and TODOList domains.
57
Mobile System Design
However, even though these domains depend on API, we will not implement the API calls for them
yet. Instead, we’ll define some placeholder code to keep things working.
We can delay the implementation of the API domain and focus on the other components first, because
from the Course perspective it doesn’t matter that Tutor, Calendar, and TODOList depend on the
deeper nested API for network calls.
Same for Store; Course does not need to know or care how Store works. All Course needs to
consider itself finished, is to send and retrieve data to and from a store. Whether Store uses a diskstore or memory cache or a real-life monkey that etches 1’s and 0’s in a tree is irrelevant to Course.
This means that we’ll properly define the interface (API) of Store but we keep the simplest implementation, because we’re sketching.
After that, we’ll go lower in the stack again. This time we will swap out the placeholder code from
Tutor, Calendar, and TODOList with code that uses API to fetch data.
We’ll design the API interface and we can consider Tutor, Calendar, and TODOList finished enough.
But again, these domains don’t care if API uses a real network call or mail-carrier pigeons. This means
we’ll write a proper interface for API, but behind the interface, API returns hard-coded values.
Finally, in the last step, the placeholder implementation of API must be replaced with a real implementation which we’ll leave as an exercise. After that, the entire course loading will “magically” work.
A nice perk of this process, is that inside a team we can now easily divide & conquer all the stuff we
have to make. Anyone can take a (parent) node, and work on that in parallel.
For example, since we’ll be starting with Course another person could start on UI or API. Then
theoretically, we could both work in parallel without touching each other’s code. Ultimately, we can
connect the UI to Course.
3.5 Implementing the Course domain
Finally, we get to do some programming.
Let’s begin. As decided, we’ll start with Course.
We start by designing how we want to use the Course from the call-site. This helps us focus on the
ergonomics, because it’s more important how we use something, as opposed to knowing about its
internals.
Because if a type’s API is solid, then we can tweak the implementation to our hearts’ desire without
having to update call-sites. Designing from the caller’s point of view helps with naming and prevents
focusing on implementation details.
58
Mobile System Design
For instance, let’s say a coworker is implementing the UI right now. To them it’s important how they can
use Course, but that coworker may not be interested in how it works under the hood. That coworker
is our “customer”, they want Course to be easy to use. Which is why we’ll focus on making Course
ergonomic to implement.
First, we have to decide how we will retrieve this Course. Let’s imagine we need a CourseAPI to give
us a Course data object.
In the listing below we retrieve a Course from the CourseAPI using loadCourse(). A Course is a
model, and it contains three properties, a tutor, schedule, and (if not nil) a calendarEvent.
The schedule is a list of todo elements – not to be confused with a scheduler that helps set up 1on1
meetings.
1 let courseAPI = CourseAPI()
2
3 // The await keyword indicates an asynchronous method, where we wait
for a response.
4
5 let course = try await courseAPI.loadCourse()
6
7 course.tutor // Tutor model
8
9 course.schedule // [TODOItem] Array
10
11
12
13 // calendarEvent is optional (may or may not be there), so we need to
unwrap it using `if let `
14
15 if let event = course.calendarEvent {
16
17
event // CalendarEvent model
18
19 }
This example is nothing special, yet. The point of this exercise is to design from the call-site. It’s
important to note that nothing works yet. The compiler will have a temper tantrum and throw errors
at us.
At this stage, we don’t know how CourseAPI works. We pretend all this code works, freeing us from
thinking about networking, testing, dependencies, and so on.
We just want to see how to get data in an ideal world.
Besides retrieving a course, we must be able to toggle todo items between checked to unchecked.
We’ll cover that when modeling TODOItem.
59
Mobile System Design
3.5.1 Course and Tutor
To finish the Course data model, let’s zoom in on its internals. Course is a tiny struct containing three
models. Notice how the schedule property is an array of TODOItem types. At this stage there is no
need to make a fancy TODOList class or something like that. So we’ll keep it simple.
There may or may not be a CalendarEvent, since maybe no 1on1 meeting is set up. Hence we mark
it as optional, depicted with a ? question mark in Swift. Which means we need to unwrap this optional
to get the value (if it’s there).
1 struct Course {
2
let tutor: Tutor
3
var schedule: [TODOItem]
4
let calendarEvent: CalendarEvent?
5 }
We’ll make schedule a variable indicated with the var keyword – as opposed to a constant, indicated
by the let keyword – so we can update the schedule (e.g. we can mark TODOItem’s as completed).
The Tutor data model has all data we need to populate the screen. Which is a name, a handle such as
"@calebGuitar". Since the tutor can drop a personal message, we need to model some sort of "call
out". We can choose to use String property called callOut. We also want to know whether the user
has dismissed the callout, for which we’ll naïvely use a isCalloutDismissed property, this will help
us figure out whether or not to show the message.
1 struct Tutor {
2
let displayName: String
3
let avatar: UIImage?
4
let handle: String
5
6
// Naïve implementation.
7
let callOut: String?
8
var isCallOutDismissed: Bool
9 }
We just got started, and there is a problem already.
The problem is that we can get Tutor in an invalid state. It can have an empty callout, yet the callout
can also be marked as dismissed or not. For instance, below we make a Tutor with invalid state.
Can you spot the issue?
1 let tutor = Tutor(displayName: "Dora bora",
2
avatar: nil,
3
handle: "@dorabora",
4
callOut: nil,
5
isCalloutDismissed: false)
60
Mobile System Design
Notice how the callout is not dismissed (so it must show), but the callout is nil. Ending up with a data
model indicating that we should show a nil callout, that doesn’t make sense.
Maybe it’s not a problem right now. But we can avoid future bugs altogether by designing a stronger
data model with proper state. To avoid impossible states in Swift, we introduce an enum called Callout.
Then we make that the property for Tutor.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// We introduce a new Callout enum
enum Callout {
case message(String) // The String is the text inside a callout.
case dismissed
}
struct Tutor {
let displayName: String
let avatar: UIImage?
let handle: String
}
// Now callout is either missing, or shown, or dismissed.
let callOut: Callout?
Now a callout can either be nil or if it’s filled, it has to be either shown or dismissed. The compiler will
be able to help us now. We can’t initialize an invalid Tutor anymore.
It’s a tiny example, but these issues add up into a weaker, less ergonomic, codebase.
3.5.2 TODOItem
The TODOItem represents one item in a todo list. Like the other models, it is a small struct. One
important thing to note here, is that intuitively we may want to have a boolean property isChecked.
But, we can get much richer information by setting a fulfillmentDate. It’s the moment in time
when that element is checked. This way, when sorting and grouping items, we can more freely group
and sort the TODO items without having to refactor this data model. Then using this date, we can
figure out whether or not the item is checked.
The takeaway here is consider richer data that achieves the same result. This gives more agility. In
our case, if we later change our mind and think "we need to show when a TODOItem is finished", then
we can achieve that without migrations or backend alignments.
Note that "is checked" is not the best fitting term. Instead of a isChecked property it’s better to use
complete. The reason for that is because we are in data-world using data-language. We aren’t in UIworld. Maybe visually an element is checked with a checkmark, but data-wise the item is completed.
But even for UI it’s not the best name; What if instead of a visual checkmark, we want to put a label
61
Mobile System Design
called "COMPLETE"? Or what if we started using toggles, then it would be onToggled or orSwitched.
In that scenario isChecked is the wrong name, and that is because how it’s used is leaking into the
name.
It’s a pedantic thing, but thinking in the right terms ensures we don’t have wrong naming bleeding
into our codebase.
Like the issue when modeling the callout, these little problems add up into a lower-quality codebase.
We’ll make complete a calculated property. It’s not a stored property (it’s basically a method pretending to be a property), meaning that it uses the fulfillmentDate property to let us know whether
the TODOItem is completed or not. Now we get the best of both worlds.
1 struct TODOItem {
2
let title: String
3
var fulfillmentDate: Date?
4
5
// A calculated property that uses fulfillmentDate
6
var complete: Bool { fulfillmentDate != nil }
7
8 }
Now that we have a fulfillmentDate, we can toggle a TODOItem by setting a date. For instance:
1 let todoItem = TODOItem(title: "Finished homework", fulfillmentDate:
nil)
2 print(todoItem.complete) // false
3 todoItem.fulfillmentDate = Date.now
4 print(todoItem.complete) // true
3.5.3 Placeholder values
We also need a mechanic to group them by weekly or daily schedule. As shown in the UI. We can do
this in the view layer, but adding this functionality in the model layers makes this code unit-testable
and usable in parts other than the view layer.
Let’s design the weekly and daily schedules from the call-site like we did before. An ergonomic way is
to ask for the daily or weekly TODOItems on the schedule array.
1 course.schedule // [TODOItem] array
2 course.schedule.daily // [TODOItem] array filtered on daily items
3 course.schedule.weekly // [TODOItem] array filtered on weekly items
Since schedule is an array of TODOItem elements, we can add a few extensions to make this happen.
We’ll extend array, and we restrict this extension where the element inside array is of type TODOItem,
so that the daily and weekly properties are only available on an array of TODOItem types. Inside
62
Mobile System Design
this extension, we’ll offer the two new properties. Just like complete on TODOItem, these properties
are computed properties, meaning that the data isn’t stored. They are methods masquerading as
properties, for ergonomics.
But, let’s think about the main goal again. Does it truly matter we figure out the dates at this stage?
All that matters is that we get an array of TODOItem types for both so that TODOItem elements are
grouped. This means that instead of figuring out dates, we can add a placeholder code such as returning
self which is the full array.
Note that we offer both a get and a set method on the computed properties. We make this distinction
because TODO items needs to be mutated so that a user can mark them as ’complete’. But, since it’s a
dummy implementation, we can ignore the setter at this stage.
1 extension Array where Element == TODOItem {
2
var weekly: [TODOItem] {
3
// Placeholder
4
get {
5
return self
6
} set {}
7
}
8
9
var daily: [TODOItem] {
10
// Placeholder
11
get {
12
return self
13
} set {}
14
}
15
16 }
17
}
Later during the process, once we’re happy with how everything works as a whole, we can replace the
placeholder self with an actual implementation. If the team changes their mind about how to group
the TODO items, we didn’t waste any time.
3.5.4 Modeling CalendarEvent
Let’s figure out CalendarEvent. Even though rescheduling a call is probably its own giant feature, in
the design all we need is read-only data about the planned schedule. We need a selected moment in
time and maybe a link to the meeting (such as a Google Meet or Zoom call). We aren’t building the
actual Scheduler for our screen, we are merely showing the date. Not having to build a scheduler
saves us a lot of work yet again.
Let’s pull up the design of the scheduler view.
63
Mobile System Design
Next, it’s time to think about how we will open the scheduler. A deeplink perhaps? Maybe for this stage
all we can do is trigger a closure when we press on “Join call”. Then we can hook up anything into the
button press, such as a deep link or a navigation flow.
Let’s try and keep it simple. In our data model we don’t have to worry about internal linking yet. Once
we start on UI where we leave the screen we can deal with that. Maybe data-wise we can assume for
now that a schedule has an external link to Google meet or Zoom.
With that in mind, the CalendarEvent will be tiny, which is good because it allows us to maintain
speed. A date and a link is all we need. Then we will represent this date in a user-friendly way once we
start making it user-facing. The link can be external, or maybe internal. We don’t know yet, but either
way we know it’s a link of some sort.
We can make the link optional in case the call isn’t started from the app.
1 struct CalendarEvent {
2
let date: Date
3
let link: URL?
4 }
That’s all there is to it.
We already noted that a 1on1 call might be missing in a course. So CalendarEvent might be nil inside
Course.
NOTE: Notice how all this deferring may look like we’re being lazy? I like to think of it as: “Not losing the
main goal out of sight”. Yes we risk shoving all “real” work away from us, but it’s important we deliver
just enough to keep momentum while we iterate. If we start doing deep implementation work to finish
components right away, we may get distracted by details; Such as trying to figure out how to link to other
features in UI or whether to build a deep link engine or which third party GraphQL library we’re going to
implement. Let’s avoid thinking about that for now and deliver something ASAP.
3.5.5 Defining CourseAPI
Now that the data models are in place, we can start thinking about how we are going to get that data.
We’ve already determined we’ll use some sort of CourseAPI for this. Let’s implement CourseAPI but
only just enough so we can keep moving and get everything compiling. This means we don’t have to
64
Mobile System Design
worry yet about real network calls, dependency injection, and testing. We will focus on those topics in
the upcoming chapters.
To get our program into a running state as quickly as possible, we’ll return a hardcoded Course element
as a placeholder.
Since we are deferring the real network (or offline store) implementation, we add a tiny delay to mimick
real-world usage using Task.sleep().
By making the API call mimic the real-world more closely, we can already see if we have some async
issues going on, despite not using a “real” implementation.
For instance, imagine a coworker is integrating CourseAPI already. If the UI were to show a loader and
hide it when this call is done, it might break since there was no chance to even load anything. Making
asynchronous code appear synchronous makes it a less realistic setting, hence why we add a little
delay.
1 final class CourseAPI {
2
func loadCourse() async throws -> Course {
3
// We add an artificial delay
4
try await Task.sleep(until: .now + .seconds(2), clock: .
continuous)
5
6
/// We return a hardcoded Course
7
return Course(tutor: Tutor(displayName: "Dora Bora",
8
avatar: nil,
9
handle: "@dorabora",
10
callOut: .message("Muy Bien. Great
job practicing Spanish last week,
keep it up!")),
11
12
schedule: [TODOItem(title: "Watch a Spanish movie
", fulfillmentDate: Date()),
13
TODOItem(title: "Conjugate `ser ` and '
estar'", fulfillmentDate: nil)],
14
15
calendarEvent: CalendarEvent(date: Date(
timeIntervalSinceNow: 3600), link: nil))
16
}
17 }
Now all that remains at this stage is adding the store functionality after which we can run our program
already.
65
Mobile System Design
3.6 The Store component
One of the important requirements is to have offline support. This can be a very tricky topic. But
using our holistic design approach, we can keep breaking down the problem until it’s less daunting to
implement.
Let’s move forward with the assumption that CourseAPI will use a new Store type for offline mode,
then CourseAPI can decide to either load the data from disk or remotely from a server.
How Store works underwater is – from the perspective of CourseAPI – an implementation detail.
To continue the holistic way of working, we will design on the Store API first; To keep the implementation as simple as possible, we will start by making Store a memory store; By finishing the Store API
and small memory implementation, we can consider CourseAPI finished enough and we can run our
project.
Then later on, we can fill in the implementation details to use actual disk storage (whether that’s a
file or database is uncertain for now). It’s okay, CourseAPI doesn’t need to know we secretly use a
memory store.
Remember, we’re sketching here.
A reason we decide to make Store a memory-store, is that it’s more important to get Store in the
system, but it’s not the highest priority to figure out how to store something. The fact that Store’s
function bodies use a dictionary or a database is not important right now, we can consider those
unimportant details at this stage. What’s more important right now is figuring out how we store
something, and how Store fits in the ecosystem.
3.6.1 Starting from the call-site of CourseAPI
As we’ve defined in code examples, CourseAPI is going to be the call-site of Store, so we’ll start
there.
Inside CourseAPI, we need to either load remotely via network, or locally from the store.
Originally, we called loadCourse() to get the Course data. But this time we’ll introduce a new
method, called loadLatest() which will load the latest Course like before.
The difference is now that loadCourse() tries to retrieve the course from a store, and if that’s not
present, it will fall back and call loadLatest() to make a network call and load the course from the
servers.
Note that we are not naming loadLatest() something like loadFromNetwork(); Because as far as
CourseAPI is concerned, the latest data can come from anywhere.
66
Mobile System Design
After we remove the hard-coded Course value, CourseAPI will get its data from TODOList, Calendar,
and Tutor. CourseAPI doesn’t need to know the details of how they load.
In the next listing, you can see how we introduce a Store<Course> property. This will be a generic
store that can store Course types in our situation. You can also see that loadCourse() retrieves a
Course from the store.
If that fails, Store will call loadLatest() to fetch a fresh Course.
NOTE: We’ll explore in a following section why we’re making Store generic.
1 final class CourseAPI {
2
3
// We introduce a Store.
4
let store = Store<Course>()
5
6
func loadCourse() async throws -> Course {
7
// loadCourse now tried to fetch from the store. If that fails,
Store will call the loadLatest method
8
let course = try await store.get(identifier: "CourseAPI.Course"
, load: loadLatest)
9
return course
10
}
11
12
// A new method that will load the Course remotely. We reuse the
same implementation from the previous example in loadCourse().
13
private func loadLatest() async throws -> Course {
14
// Same placeholder to fetch data remotely.
15
try await Task.sleep(until: .now + .seconds(2), clock: .
continuous)
16
return Course(tutor: Tutor(displayName: "Amelia bedelia",
17
avatar: nil,
18
handle: "@amelia",
19
callOut: .message("Muy Bien. Great
job practicing Spanish last week,
keep it up!")),
20
21
schedule: [TODOItem(title: "Watch a Spanish movie
", fulfillmentDate: Date()),
22
TODOItem(title: "Conjugate 'ser' and '
estar'", fulfillmentDate: nil)],
23
24
calendarEvent: CalendarEvent(date: Date(
timeIntervalSinceNow: 3600), link: nil))
25
26
27
}
28 }
67
Mobile System Design
NOTE: We can make loadLatest() private since nobody needs to call it directly, except CourseAPI
itself. Keeping the API surface small of a type is a good habit to get into when designing new types. It
hides complexities from the outside in the long term, and for now it makes it easier to figure out what to
use from the call-site, such as when using autocomplete.
3.6.2 The Store implementation
We decided that the Store itself is initially a memory store, meaning that we can store values inside a
property. For our goals, a dictionary – also known as Hashmap – should suffice.
Store will return whatever it stores, which we denote with a generic type T. From the perspective of
CourseAPI, the Store is of type Store<Course>. But from inside Store we define the stored type
as T, a generic parameter. Hence, we define it as Store<T> in the implementation.
NOTE: T stands for type and is a convention for a generic parameter.
To store the data, we define a dictionary – which we’ll creatively call data – where we’ll store the
elements. Its keys are the identifier strings given to us via get() and the values are the data we want
to store.
The get() method will return the data of type T. We haven’t figured out the signature of get() yet so
we’ll mark it with ???.
Let’s take a look at what we have so far.
1 final class Store<T> {
2
// We define a dictionary, or hashmap. The keys are identifier
strings, the value are of type T (can be anything)
3
var data = [String: T]()
4
5
func get(???) -> T {
6
// Implementation and function parameters are omitted
7
}
8
9 }
3.6.3 Designing the get() method signature
We know how we want to use get() from the call-site in CourseAPI, using that information, we can
design the function signature. After that, we’ll create the implementation.
The get() method needs an identifier, we used a regular String. Second, it needs a closure that will
actually load the data. We know this from how CourseAPI was calling the get() method. The closure
accepts nothing, and returns the type inside Store, which is T.
68
Mobile System Design
The closure can look something akin:
1 load: () -> T
But, the closure must be able to throw errors. Since get() doesn’t know what the closure does inside
of it, and we know that CourseAPI will pass a throwing closure. Let’s update the signature to reflect
that using the throws keyword.
1 load: () throws -> T
The closure loads asynchronously. For instance, sometimes it will load Course data from a network
call. Let’s update the signature with the async keyword.
1 load: () async throws -> T
Let’s update get() with this information.
1 final class Store<T> {
2
var data = [String: T]()
3
4
func get(identifier: String, load: () async throws -> T) -> T {
5
// Implementation omitted
6
}
7
8 }
A few things are missing, because load() is asynchronous, this makes get() asynchronous too. As a
result, we need to add the async keyword to the get() method.
1 final class Store<T> {
2
var data = [String: T]()
3
4
// We add the async keyword near the end of the method declaration
5
func get(identifier: String, load: @escaping () async throws -> T)
async -> T {
6
// Implementation omitted
7
}
8
9 }
One last thing specific to Swift: We need to add the rethrows keyword to get(). Because any errors
from load() will not have to be handled by the Store itself, because Store doesn’t know what
happens inside the load() closure.
Instead, Store will propagate the error up to the call-site, which would be CourseAPI in our case. We
achieve this “closure error propagation” by adding the rethrows keyword to the get() signature.
69
Mobile System Design
1 final class Store<T> {
2
var data = [String: T]()
3
4
// We add the rethrows keyword near the end of the method
declaration
5
func get(identifier: String, load: @escaping () async throws -> T)
async rethrows -> T {
6
// Implementation omitted
7
}
8
9 }
Great, that finishes the Store’s API.
The method signature won’t win any beauty awards, but that’s only a concern for the owners of this
code.
Because all these keywords make it easier for the call-site, which is far more important. A little pain in
the API to help out your “component customers”, or call-site, goes a long way.
3.6.4 Implementing the get() method
Next, we’ll implement get(). First, we check if the data under the passed identifier exists (as stored
in data), if the data exists we’ll return it. If the data does not exist, then get() will call the passed
load() closure which will load the data, put it in the store. Finally, get() returns the new data.
1 final class Store<T> {
2
var data = [String: T]()
3
4
func get(identifier: String, load: @escaping () async throws -> T)
async rethrows -> T {
5
if let storedData = data[identifier] {
6
return storedData // Data already exists, return it
7
}
8
9
let freshData = try await load() // Data doesn't exist, load it
asynchronously
10
11
data[identifier] = freshData // Store the new data
12
return freshData // Return the new data
13
}
14
15 }
70
Mobile System Design
3.6.5 Store is implemented naïvely on purpose
Store is a bare-bones solution. With a little thinking, we can already come up with some issues; What
if data is stale? What if we need to update only partial data? How often do we need to refresh the data?
What about out-of-sync errors? What if the app fetches data while a user is updating TODO’s locally,
causing race conditions? How would we even update local data ourselves? What if we want to mix data
types? Never mind that Store is not thread-safe.
Clearly, Store is a naïve implementation, and that’s intentional. Store ’s API is thought-out enough
for this phase, and that is a good enough starting-point for us to move forward, and that’s the goal.
Right now, connecting everything together is more important than spending all our time on making a
complete Store implementation.
3.6.6 Placeholder implementations lower priorities
It’s important we end up with a store, but now that we’ve made a naïve implementation with non-naïve
API barriers, CourseAPI can be considered finished, resulting in a lower priority to finish Store. In
other words, fully implementing Store right now is not the highest priority anymore.
Who knows, we may decide we don’t need offline mode after all, or maybe a memory store is enough.
In which case, it’s no big deal since we hardly spent time on it!
What is important, however, is to think of the APIs and how everything works together. We can still
refactor and rename, but having a broad-strokes idea of how everything connects is most important,
it’s the foundation of our program.
The implementation details will follow.
3.6.7 Trade-offs when making a component reusable
You may wonder why we make Store generic, since we only use it for one type.
For example, Store is of type Store<T>. It’s not something more specific, such as CourseStore.
The answer to that is that we can make a store that can only accept courses. But, looking at the
landscape, we see a store is outside of the tutor and course domain. Store is its own thing.
Notice how Store is its own node in the graph. Course is above Store in the graph. That means that
Course can use Store. Vice versa, Store is lower in the graph than Course. This implies that Store
isn’t allowed to know about domains above it, in this case that’s Course.
71
Mobile System Design
Theoretically, we could make a CourseStore and put it higher in the graph. But then we gain nothing
and lose flexibility. We muddy up the domains too. Store stores nothing but bits and bytes. Why does
it need to know about courses? Why would Store need to be in the Course domain? It wouldn’t make
sense.
Store is in its own domain. Making it safe to make a standalone component.
For some, Store<T> is harder to read than CourseStore. But, from the call-site inside CourseAPI
the type isn’t Store<T>, it’s Store<Course> which is quite clear, I would argue. It’s only the code
maintainers that need to know about the generic internals and <T>’s and <U>’s and other arbitrary
letters.
Sometimes you may hear people say, “Don’t make code reusable until it’s used three times”. But this is
a counterexample of when you would want to start with a generic component.
Note how we’re not saying “We’re making it generic because it’s useful for later”, that’s an overengineering pitfall. We’re making it generic because Store is in its own domain and serves as a
foundational component.
3.7 Performing a little testrun and moving forward
Using Holistic Design, we finished the Course domain, ready to be implemented by UI.
Looking at all components from a domain perspective, we can see inside each domain and look how
things are connected.
NOTE: We are depicting classes and domains with rectangular nodes, to indicate we’re looking at our
system on a code-level.
72
Mobile System Design
We’ve written just enough code to get things compiling. We’ve defined the data, and we made a
functioning CourseAPI with a fake placeholder implementation, which uses a functioning – albeit
naïve – Store that stores data in memory.
Getting our program in a running state is a nice checkpoint to see where we are at. Let’s give our
program a try, shall we?
Below we’ll run the same code we defined at the start. The difference is that it now compiles.
1
2
3
4
5
6
7
8
9
10
11
12
let courseAPI = CourseAPI()
let course = try await courseAPI.loadCourse()
print(course.tutor)
print(course.schedule)
if let event = course.calendarEvent {
print(event)
}
// Will print:
Tutor(displayName: "Amelia bedelia", avatar: nil, handle: "@amelia",
callOut: Optional(Callout.message("Muy Bien. Great job practicing
Spanish last week, keep it up!")))
13 TODOItem(ID: 859835686025072721, title: "Watch a Spanish movie",
73
Mobile System Design
fulfillmentDate: Optional(2022-12-19 09:51:03 +0000)), TODOItem(ID:
1271749903617373574, title: "Conjugate \'ser\' and \'estar\'",
fulfillmentDate: nil)]
14 CalendarEvent(date: 2022-12-19 09:51:03 +0000, link: nil)
Perfect, it works! The compiler is happy again.
3.8 Focusing on UI vs other implementations
Now that we have some initial code, let’s go over which direction to go next. There are many things we
can work on (tests, setting up project more, UI, etc), but looking at the landscape we have two main
options.
We can go deep and we finish the components we outlined. Then we will also have to think about
backend integration and so forth.
Or we go high and start connecting Course to the UI.
Let’s think of some points that help us decide.
3.8.1 Focusing on UI
One option is to go higher in the domains. Which means we could connect the Course component to
our UI environment. Initially, the UI would be without styling, merely a bunch of labels and simple
buttons on the screen. This is a good idea to figure out how we will connect the UI to data.
By focusing on UI, we go up in the domains, above Course. In the graph, we could see how UI (marked
as dashed, indicating it’s not figured out) would use the Course component.
74
Mobile System Design
Moving on to UI would be great to have something to show quickly. It’s also good to work with the
designer and focus on some details. It’s the quickest path to perform user tests, iterate over UI, think
about connecting UI with a navigation flow, and so on.
It may sound silly, but don’t underestimate how excited people can get seeing UI getting finished. Both
technical and nontechnical people will see a lot of progress with UI, feel there is momentum, maybe
you can even distribute a build, so it’s in their hands for them to play around with it. Managers see
your good work, and so on.
It’s great for marketing, too. Maybe you can even show a product demo video to get new customers
excited!
Whereas working below the UI surface (e.g. focusing on data, parsing, and backend implementation) is
less visible. People are content it works and you’re moving tickets on a sprint board, but they don’t
always understand what work you did. It’s more of a black box to them.
One psychological angle to consider is that starting with UI can make the app feel quite finished early
on. After which there is a ton of invisible work left to finish all parts hidden from non-developers.
This may give a skewed view of why at first you seem really fast, and then later on you may appear to
be a slower developer. If you’re going down this path, you will end up with finished UI and unfinished
invisible work. Expect to explain more what invisible parts you’re working on.
A simple remedy for this is to defer delivering polished UI until later, when all parts are in place. You
could make placeholder UI and connect that to real data – as opposed to polished UI with fancy
animations.
Another risk of focusing on UI first is that you’ll be making good-looking UI in a safe bubble, and then
once that’s finished, you’d start with the network implementation and realize quite late that there are
severe misalignments in your team.
Whereas if you start the full integration earlier, you can sound the alarms early if needed. You’ll be able
to “activate” team-members to make critical updates while you work on other things, thus saving time
in the process.
3.8.2 Going deeper
Another option is to continue with our holistic-driven development approach and start going deeper.
Thus implementing the remaining code. Such as writing the API implementation code that’s used by
Tutor, Calendar and TODOList.
75
Mobile System Design
Going deep is a great option to focus on the integration of systems and to uncover hidden issues.
It’s a good idea to figure out all communication with the backend, align with backend engineers, deal
with parsing and submitting. You might bump into issues with the staging environment not always
being up-to-date. Or realize you need to pay for a login token for a third party analytics library.
Issues with integrations can come from any angle. Maybe a backend engineer implemented the wrong
fields, or maybe made different fields optional than the designer intended, which reflects back into
the UI.
Or maybe you’ll trigger an extensive discussion about notifications and how often they should happen.
Or maybe you’ll be able to show that notifications aren’t working in the first place; Allowing you to
inform your coworker before they go on their honeymoon.
Another benefit of going deep is that it forces you to think about testing and mocking I/O and network
calls, which is why we’ll cover those topics in the following chapters.
3.8.3 A team setting
In a team setting, you can more easily divide the work over the components that need to be implemented. For instance, one person can work on the CourseAPI functions with placeholders, while
another can connect it to UI, and a third person can focus on the remaining domains, such as API. By
focusing on the interface of each component, you can stay out of each other’s way and reduce the
chance of merge conflicts.
After the work is done, the puzzle-pieces should fit together nicely.
Whatever you choose to focus on in a real-world setting, I would recommend to go deep. Focus on things
that are most uncertain and/or require the most effort if things unexpectedly go wrong. Integration is
often time-consuming since it reveals misalignments and wrong assumptions.
76
Mobile System Design
As a rule of thumb, push the integration as early as possible – don’t be all snug and safe in your UI
bubble making the fanciest animations.
During a job interview, it kind of depends what your interviewers want to see at this stage.
Maybe they want you to cover UI, or move on to talk about navigation flows. But most likely they’ll
want to throw some curveballs at you. Such as adding tricky requirements and see how you handle
that. For example, they may scale up the feature, such as asking how this feature would work with
infinite courses and tutors and having to store large amounts of data.
3.9 Implementing on a deeper level
Let’s cover how it would look like if we were to continue the implementations on a deeper level using the
holistic approach. We’ll be working downwards in the landscape graph. We achieve this by repeating
the same process as before.
Again, we’ll start as high as possible in the landscape. Since we already defined Course and CourseAPI
from the call-site, we will now go one step lower.
We’ll take the highest-level placeholder code, which is inside the loadLatest() method of
CourseAPI. We’ll replace it with the actual code that we need.
First, let’s grab the CourseAPI code we had:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final class CourseAPI {
// ...
snip. To keep this listing smaller
func loadLatest() async throws -> Course {
// We will replace this code
try await Task.sleep(until: .now + .seconds(2), clock: .
continuous)
return Course(tutor: Tutor(displayName: "Amelia bedelia",
avatar: nil,
handle: "@amelia",
callOut: .message("Muy Bien. Great
job practicing Spanish last week,
keep it up!")),
schedule: [TODOItem(title: "Watch a Spanish movie
", fulfillmentDate: Date()),
TODOItem(title: "Conjugate 'ser' and '
estar'", fulfillmentDate: nil)],
calendarEvent: CalendarEvent(date: Date(
timeIntervalSinceNow: 3600), link: nil))
77
Mobile System Design
18
19
20 }
}
All we have to do is remove the placeholder inside loadLatest(). Then we’ll design the real code
from the call-site, just like before.
To get the real data to fill a Course, CourseAPI would need to combine data from the Tutor,
Calendar, and TODOList domains. Each domain will need some sort of type to load from, let’s call
them tutorAPI, todoList, and calendar and add these as instances.
With these instances we can load the data inside loadLatest() and we’ll give that data to a new
Course instance.
1 final class CourseAPI {
2
// ... snip
3
4
// We add new instance properties to load our data.
5
// But note that we didn't yet introduce TutorAPI, TODOList, and
Calendar types.
6
let tutorAPI = TutorAPI()
7
let todoList = TODOList()
8
let calendar = Calendar()
9
10
func loadLatest() async throws -> Course {
11
// We introduce the real code.
12
let tutor = try await tutorAPI.loadTutor()
13
let schedule = try await todoList.loadSchedule()
14
let calendarEvent = try await calendar.loadEvent()
15
return Course(tutor: tutor,
16
schedule: schedule,
17
calendarEvent: calendarEvent)
18
19
20
}
21 }
Whether the three new types load real or placeholder data is not important to CourseAPI. We can
consider CourseAPI finished at this stage, since it loads data the “right” way.
Note that TutorAPI, TODOList, and Calendar don’t exist yet. At this stage, the compiler will throw
errors again. But now we know what to do: We can define these three new types and make it compile
again using placeholders. We effectively push the placeholders one level deeper.
78
Mobile System Design
3.9.1 Defining new types
Let’s look at the three new types. Notice how we moved the placeholder implementations into their
function bodies.
Like before, we’ll add a delay before each placeholder to mimic real-world behavior.
1 final class TutorAPI {
2
func loadTutor() async throws -> Tutor {
3
try await Task.sleep(until: .now + .seconds(2), clock: .
continuous)
4
5
return Tutor(displayName: "Amelia bedelia",
6
avatar: nil,
7
handle: "@amelia",
8
callOut: .message("Muy Bien. Great
job practicing Spanish last week,
keep it up!"))
9
}
10 }
11
12 final class TODOList {
13
14
func loadSchedule() async throws -> [TODOItem] {
15
try await Task.sleep(until: .now + .seconds(2), clock: .
continuous)
16
17
return [TODOItem(title: "Watch a Spanish movie",
fulfillmentDate: Date()),
18
TODOItem(title: "Conjugate 'ser' and 'estar'", fulfillmentDate
: nil)]
19
}
20
21 }
22
23 final class Calendar {
24
func loadEvent() async throws -> CalendarEvent {
25
try await Task.sleep(until: .now + .seconds(2), clock: .
continuous)
26
return CalendarEvent(date: Date.now, link: nil)
27
}
28
29 }
NOTE: You may wonder why we make all classes final. Making classes final is a good habit to get
into, since it avoids others from sub-classing our classes which we don’t intend to support now (or ever).
Otherwise, someone might rely on sub-classing our classes, which we consequently could break.
We can keep continuing with this process until we finish every domain and implementation.
79
Mobile System Design
3.9.2 Optimizing CourseAPI
A benefit of designing APIs is that we can easily iterate inside their function bodies without affecting –
or having to refactor – other types.
Looking at CourseAPI, it’s loading each element one by one, not in parallel. What we can improve
here is making sure that tutor, schedule, and calendarEvent all load simultaneously. Then we’ll
make the code wait with a so-called synchronization point. Until all loading has completed, only then
Course will be created.
To make the loading work in parallel, we’ll replace each await step to an async let step. Then we’ll
mark the Course creation step as the synchronization point by using the await keyword.
This code...
1 final class CourseAPI {
2
func loadLatest() async throws -> Course {
3
// First tutor loads
4
let tutor = try await tutorAPI.loadTutor()
5
// After that, schedule loads
6
let schedule = try await todoList.loadSchedule()
7
// After that calendar loads
8
let calendarEvent = try await calendar.loadEvent()
9
return Course(tutor: tutor,
10
schedule: schedule,
11
calendarEvent: calendarEvent)
12
13
14
}
15
16 }
... becomes . . . .
1 final class CourseAPI {
2
func loadLatest() async throws -> Course {
3
// The data fetching below all run in parallel, indicated by '
async let'
4
async let tutor = try tutorAPI.loadTutor()
5
async let schedule = try todoList.loadSchedule()
6
async let calendarEvent = try calendar.loadEvent()
7
// Synchronization point: Now we await at Course, which is
reached when all three parallel calls above are finished.
8
return try await Course(tutor: tutor,
9
schedule: schedule,
10
calendarEvent: calendarEvent)
11
12
13
}
80
Mobile System Design
14
15 }
3.10 End result
That was a good day’s work. We can look at the landscape graph and see which domains we have
designed. To continue finishing Course, can see that the remaining domain is API.
We’ll leave implementing API as an exercise. But suffice to say, it would consist out of straight-forward
network calls you will find in the majority of mobile apps.
A more complicated task related to API is dealing with Dependency Injection, testing, and mocking,
which is what we’ll cover in the next chapters.
3.11 Reflecting on holistic driven development
We have an entire working program. Except, all the I/O stuff is not working yet. We don’t fetch from a
real network call, and we won’t use a file store yet. That’s the beauty of it.
Even though we have a simple solution, we already figured out what to make, made some implementations, we can easily divide the work amongst teammates, and we can already have someone hook
up UI to our components.
Not to mention, we have strongly divided domains. Thanks to separate domains, we have the flexibility
to leave the code as is, or move things to their own repository, open source it, or make a local package.
We have an entirely functional CourseAPI, which is the heart of our operation. It’s a starting point for
us to connect it with UI if we wish to do so.
To revisit the drawing analogy: We’ve drawn the lines, and now we can start coloring in everything.
81
Mobile System Design
The holistic approach is great for turning ideas and specs into actual code. There is a lot of designing
and sketching going on, hence we place more importance on the APIs of the components and working
initially with placeholders.
The real benefits are that it allows us to refactor quickly, and even delete code without sacrificing too
much time. An even bigger benefit is that it allows us to keep focusing on the highest priority work. For
instance, having a Store is high priority. But taking a shortcut and making it a memory store reduces
its priority to a low priority.
With this approach, we can check after each step “What is most important?” and focus on the next
thing, whether that’s removing placeholder code or creating a new component. In essence, we are
jumping all around over the codebase.
Holistic driven development is a great approach when dealing with a lot of unknowns or new code.
Let’s review its pros and cons a bit more.
3.11.1 Holistic-Driven Development brings confidence to move forward
With the approach in this chapter, you can move forward without knowing implementations details.
For instance, let’s assume you know you need to implement a MySQL database for local storage, but
you’ve never done that before. By defining the API with a placeholder implementation, you can delay
the “real” implementation.
Meanwhile, you can educate yourself about MySQL at your own leisure, after which you’ll be better
prepared once you need to implement it for real.
In our case, when the time is right, we can replace the function bodies so that they use an actual MySQL
store. Maybe we could tweak its interface a bit without changing a large portion of our app, and we’re
done.
3.11.2 Lightweight restructuring
Since we think in domains and a component hierarchy, it makes it quite lightweight to throw components all around.
For instance, let’s say we think Store is in the wrong location. Maybe we decide later on that
CourseAPI doesn’t need to know about Store. Maybe we decide that it’s better to only store
TODOList data, or even more drastically; We may decide that we want to cache all network calls and
make API use Store underwater.
82
Mobile System Design
It won’t be a problem. Since Store is a standalone component, it can live anywhere. Since we didn’t
lose too much time on implementations, it won’t be painful to rethink our decisions and change things
up.
3.11.3 Context switching and delegation
A downside of this holistic method is that you are jumping all over the place, finishing all components
little by little, as opposed to finishing one component at once.
Every component stays in an unfinished, unresolved state for much longer. Mentally speaking, it can
take more space in your brain because nothing really gets resolved yet.
It’s like moving into a new house and being reminded for months that the baseboards aren’t finished
yet.
Even though this chapter showcases the benefits of the holistic approach, there is a downside. It’s
more difficult for people who really want to zoom in and focus on a core piece, get it finished and move
on and forget about it.
But we can make it our advantage in a team setting. Imagine a lead engineer that oversees the entire
landscape and app production. Using the holistic approach, they can delegate smaller domains and/or
components to people that prefer to focus on a single part.
For instance, one person can focus on Store, and the other can focus on TODOList, and another can
focus on implementing UI. Meanwhile, none of them don’t need to know the entire system to perform
their work. By focusing on APIs, the team lead can ensure each part is connected while they’re being
worked on.
The holistic approach can be much faster in a team setting and it scales; Engineers don’t need to know
about the entire stack of a large application just to perform their work. Now, most engineers can focus
on individual domains.
3.11.4 Top-down versus bottom-up
The holistic development approach is a top-down approach and may not fit your way of working.
We start at the top of the landscape, and then we will work down to the layers below until it works.
But, sometimes it can be better to start at the bottom. Such as when you depend on a core technology
and you’re not sure if that will work for your use-case.
Let’s say you base your entire app on the premise that it runs on the background full-time. But, the OS
doesn’t let you. It’s a good idea to learn about these limitations in details and figure out a way to work
around them before you implement the other domains.
83
Mobile System Design
3.11.5 We delay writing tests
The approach we took is similar to Test-Driven Design but without the testing. It sounds worse, doesn’t
it?
Like our approach in this chapter, with Test-Driven Design, we also write just enough code to move
forward at each step. A major selling point of that approach is that we test our code right away.
The reason we aren’t writing tests yet, is because we’re constantly figuring out code, moving things
around, renaming things, deleting parts, and so on.
Having to not only refactor production code but also testing code would slow us down. In this process,
we keep deleting code too. Writing tests only for us to delete them shortly after is a waste of time.
Another reason we haven’t tested yet is because we will focus on testing specific areas. We avoid having
to make everything testable, otherwise we risk ending up interfaces everywhere. It’s a contentious
topic, I know. We’ll cover all of this in the testing chapters as well.
Now that we have a structure in place – maybe even call it an architecture – we are in a suitable position
to thinking about testing.
3.11.6 Why we don’t design with interfaces or protocols instead
Since we focus so much on the interfaces, or APIs, of concrete types, you may wonder why we do not
use actual interfaces for everything instead.
The problem is, even if we were to write interface types, we still need concrete implementations as
well. If we were to define interface types, we don’t have a program. We need concrete types, which is
why we focus on that first.
The interface types will come naturally when we need them, not upfront.
Now Swift has this alluring feature called “Protocol-Oriented Programming” where you can add methods on interfaces and design your program around that. Someone might propose we use that and you
can, but you will be dramatically increasing the difficulty level to understand a codebase. And, you still
need concrete types.
You will be doubling all types (each implementation now has a protocol), but now the methods live on
protocols with no initial benefits. Thus making it harder to find these methods and making it harder to
reason about what type is overriding what protocol implementation. This technique definitely has its
use, but not in this small and fresh codebase.
A main reason to use interfaces is to make code polymorphic so we can swap things out. But, we
only need one TODOListAPI, we only need one CourseAPI. So why should we add more interfaces,
84
Mobile System Design
such as TODOListLoader and CourseAPIProtocol? That would only make the codebase more
convoluted.
The names TODOListLoader and CourseAPIProtocol are not the best names, and that’s the point.
In a real-world application, people struggle to name these “mirror” interfaces, me included. It indicates
a code smell.
One might say “But by using interfaces we can make things so much more testable!”. Aha, there is the
culprit, thinking every type needs an interface for testing is a smell. We’d be making the production
codebase more porous just for testing. We will cover this in depth in the testing chapter, where we’ll be
able to have high test-coverage by introducing fewer interface types.
3.11.7 A note on placeholders
We’ve used various placeholders, we returned hardcoded structs, we returned self inside an Array
extension, and we use Task.sleep to mimic a real-world delay.
One placeholder method we haven’t discussed yet that Swift supports is to make the app crash using
fatalError(). This way you don’t have to return anything from functions that require you to do
so.
Below we’ll make a function that returns a number, but we make it crash using fatalError, and we’ll
use #function to make the crash message refer to the current function. This allows us to compile
and run the code, yet we don’t have to worry about returning a value.
1
2
3
4
5
6
7
8
func giveMeANumber() -> Int {
fatalError("\(#function) is not implemented")
}
// Running this...
giveMeANumber()
// ...will return:
"Fatal error: giveMeANumber() is not implemented"
Personally, I wouldn’t use this as a first approach. Holistic-driven development works better to keep
the app in a working, running state. It’s usually a low effort to return a placeholder.
But using fatalError is definitely an option when a placeholder when you have trouble returning
a default value. A secondary benefit of this approach is that it offers an easy method to find all
placeholders. All you have to do is search for fatalError. And, with the fatalError approach,
you’ll know right away when it’s hit when running your application.
85
Mobile System Design
3.12 The takeaways
In this chapter, we covered:
• Focus on designing component APIs to keep speed and the ability to move things around
• You don’t need interfaces to design component APIs
• Use placeholders religiously until you get your code to compile
• Try to deliver concrete code for simplicity
• Generic code can make sense for foundational components
• One by one, replace placeholders with real implementations one abstraction layer at a time
while you figure out the details
• By introducing components, you can update implementations behind the scenes, without disrupting other classes or call-sites
• We delay writing tests because code at this stage is volatile
• By focusing on UI, you can give the impression your work is progressing faster than it really is
• Favor integration with backend over polishing UI to find integration issues
86
4 System-wide testing; Delivering higher quality
apps
In this chapter
• Figuring out how high in the stack you need to test.
• Downsides of testing small units.
• Writing system-wide tests using unit-tests.
• How to handle boilerplate during testing.
• Benefits and downsides of system-wide tests.
In the previous chapter we briefly touched upon testing, and more specifically why we didn’t test yet.
The reasoning was that we were still in a sketching phase and it wouldn’t make sense to test code that
was prone to drastic changes. But now we are one step further, we have a working system in place that
(hopefully) we’re happy about.
The world of testing is a complex and a highly opinionated field. I assume you have your own methods
and this chapter isn’t here to convince you to throw all of that away. Entire books, papers, and casestudies have been written about testing. To think that we can define "the one best testing method" in
a single chapter would be absurd – there are no silver bullets.
Having that said, let’s take a moment to cover some different considerations and share some unconventional ideas that are highly effective and battle-tested in the world of mobile development, for both
big and small apps.
There are many ways to tests, including, but not limited to; manual testing, unit-tests, UI-tests, integration tests, and so on. To maintain our velocity, we will focus on unit-tests in this chapter, because
it most closely resembles the steps developers would take at this stage of development. Later in the
book, we will focus on other testing methods to further increase the quality of our codebase.
4.1 Testing less granularly
To make code testable in our industry, often we’re being told to use a ton of interfaces, mocking, and
stubs.
87
Mobile System Design
However, because we are testing for mobile apps, we’ll go against the common path. We will put a strong
emphasis on mocking as least as possible, while trying to test the largest code-surface possible.
This will ensure that we spend less time writing tests – something many developers can appreciate –
while still maintaining the integrity of our code.
We will be approaching something people might call system testing. Where we test our code as a
system, as opposed to tiny units.
This approach comes with its own considerations and downsides. It’s a fine balance between unit tests
and integration tests that lends itself extremely well to a mobile environment.
The end goal would be that before you merge your code, you have a lot of quality guarantees that the
system works together as a whole.
As a side-effect, we will have fewer interfaces – or protocols in Swift terms, which makes it arguably
easier to reason about a codebase.
In the end, we want to best optimize our time and write as few tests as possible, while still making sure
we lower the risk of bugs.
To take the idea one step further, in the next chapter, we will cover which domains we’ll prioritize to
get the most testing done with the least amount of effort.
4.2 Damage control and damage prevention
Before we begin to dive into unit-tests, let’s first get a deeper insight into the mobile app release process,
and how this affects the way we test our apps.
Unlike web or backend engineers, we don’t have the luxury of quick rollbacks and auto-deploying
multiple times a day. Having to roll back breaking changes isn’t great, but reverting a release is an
option that mobile engineers don’t have. We can’t “un-update” people’s apps. We can only move
forward.
We can’t perform granular releases – with some exception aside. We repeatedly submit one build at a
time which contains all features from all teams. Whereas web developers, for example, can update a
feature behind an endpoint such as /profile but leave everything else intact. But as mobile engineers,
we don’t have the luxury of deploying a single screen without submitting a new binary.
When releasing a new version, we tend to painstakingly make a production build, run a ton of checks
and tests, and prepare the build with steps such as localizing our strings (also referred to as ‘copy’)
and updating the marketing department to give us a new description for an app store update. In some
companies, the mobile-engineering department will spend a week to verify the build while we apply
hot-fixes on the branch when necessary.
88
Mobile System Design
After applying some last-minute tweaks, we check everything again, after which we submit the binary
to the app store. With iOS, we impatiently wait for the build to be blessed by the fruit overlords; They
might even reject the build, which means we may need to contest and/or submit a new build all over
again.
Then, once the build is ready to be released, we may still not fully release. Instead, we may choose to
release gradually using a staged rollout mechanism. In this scenario, the app stores slowly drip out the
latest version to the users. Meanwhile, we keep a close eye on the number of crashes and issues that
may arise.
That, sadly, is the best-case scenario.
4.2.1 Damage control
In larger companies, successful releases are a ton of work; In large apps, release-blocking bugs can
come from any angle. Maybe your team is about to release an exciting new giant onboarding flow, but
unfortunately, another team might have broken the payment screen. This might stop a release and
delay your fancy new onboarding feature.
After releasing a build, if there is a major bug or crash, then that’s a big problem for the mobile
department, or even the entire company.
Now we’re in damage control mode.
We can stop a staged rollout if we’re on time, but that might be too late if the crash reports aren’t
alarming initially. One option to combat a broken build is hot-fixes, but hot-fixes are more like lukewarmfixes in the world of mobile. Because we can’t quickly merge code and get it deployed right away. We
need to make a new build, and for iOS we have to go through an expedited review by Apple. It’s a pain
and can easily cost a day minimum.
One way to get more control in the release procedure is by implementing feature flags, which is a
system where we put client code behind a backend-powered flag – like a boolean, that the mobile
client uses to enable/disable certain code-paths or entire features.
Feature-flags allows us to release features gradually. In case of an incident, we can remotely disable
features altogether. It’s highly recommended to implement feature-flags, but it’s not a foolproof plan.
Feature-flags might not always be well-implemented or maintained – because a large app might
contain a large number of flags – and apps can crash on startup before fetching feature-flags, which is
a quick and easy way to raise one’s blood-pressure.
It’s imperative to have strong release processes and damage control. But, on the flip-side of that coin,
there is damage prevention. A way to protect quality before going live, which is the aim of this chapter,
and it affects how we reason about our tests.
89
Mobile System Design
4.3 Mocking higher in the stack
Let’s go over a scenario where we will apply two similar strategies.
One strategy will be a common approach to testing with isolated unit-tests.
The other strategy will be a system-wide approach to testing. Its goal is to get more quality checks
earlier in the development of a feature. This should give us more confidence that the release build is
stable.
We’ll start with the common approach, after which we’ll cover its shortcomings.
As an exercise, imagine we’re expanding our app with a VideoClient functionality, allowing students
to upload their progress and show to their tutor.
For instance, a student learning guitar can upload a video of their song for analysis. Or in another case,
a student learning Spanish might have trouble pronouncing certain phrases, they can then upload a
video to ask their tutor for help. This allows students to get asynchronous help, as opposed to having
to wait for 1on1 calls with their tutor.
This VideoClient requires a bunch of logic. For example, it can read media from a hard-disk, perform
bit-rate conversion, resume partial uploads, and offer background uploading.
VideoClient exists in its own Video domain and needs other components to function.
Inside the Video domain. Let’s visualize the classes we’ll be using. We can see specific types up top,
which depend on more generic types at the bottom.
NOTE: We’ll use rectangles to represent types, such as classes.
90
Mobile System Design
NOTE: Video uploading has a lot more moving parts, such as chunking data, resuming uploads in the
background, and so forth. For this exercise, we’ll keep it small as a naïve solution for demonstration.
VideoClient relies on VideoAPI to upload videos, which uses API underneath for the raw net-
work calls. VideoClient also doesn’t compress and optimize videos directly, it uses an underlying
VideoCompressor for that.
Users of VideoClient can create a VideoClient instance, pass it some data, and the VideoClient
will perform the necessary actions to get the video uploaded.
1 let videoClient = VideoClient()
2
3 // VideoClient will internally compress and upload the data by calling
upload
4
5 let result = try await videoClient.upload(videoData: myVideoData)
4.3.1 Isolating VideoClient
Next, let’s say we decide to unit-test VideoClient. Since network calls to production servers are
something we don’t want in unit-tests, we have to mock it out.
One approach we could take is to mock VideoAPI so we can test VideoClient in isolation without
relying on the API class from that domain.
91
Mobile System Design
We are introducing a mock one layer right below the class we want to test, VideoClient in this case.
This is a common approach that developers will take. Because it’s convenient, and it allows us to
quickly and easily test VideoClient as an isolated unit.
But, this approach comes at a price. Before we cover its downsides, let’s first continue and explore
how this approach will work, then we’ll try an alternative approach.
4.3.2 Introducing mocks
During testing, we’re going to swap out VideoAPI with a MockVideoAPI, allowing us to test
VideoClient without depending on real API calls.
To achieve this, we need to define an interface, let’s call it VideoAPIProtocol, which allows us to
upload videos.
NOTE: Already one downside becomes clear. Because we are mirroring VideoAPI, it becomes harder to
come up with a name for its interface and we lazily suffix it with Protocol which is what you commonly
see in the wild. Naming is already hard for types, let alone coming up with two names for similar ideas.
Instead of depending on VideoAPI, VideoClient will depend on this VideoAPIProtocol, allowing
us to pass various types to VideoClient, as long as they conform to the protocol.
Below we’ll define the VideoAPIProtocol and VideoClient.
Notice that VideoClient can instantiate its own VideoCompressor since we are not swapping
anything out there.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
92
// We define the interface, or `protocol ` in Swift terms.
protocol VideoAPIProtocol {
func uploadVideo(data: Data) async throws
}
final class VideoClient {
// The videoAPI is passed through the initializer and stored here.
private let videoAPI: VideoAPIProtocol
// The videoCompressor is instantiated here and doesn't need to be
passed in.
private let videoCompressor = VideoCompressor()
// We pass the protocol to VideoClient via its initializer.
init(videoAPI: VideoAPIProtocol) {
self.videoAPI = videoAPI
}
func upload(videoData: Data) {
Mobile System Design
20
21
22
23
24 }
}
// ... implementation omitted
// ... snip
Next, we define the VideoAPI, conforming to VideoAPIProtocol, which implements the
uploadVideo(data:) method that uploads a video to the server. VideoAPI can initialize its own
API client.
1 // The "real" VideoAPI that will upload videos in production.
2 final class VideoAPI: VideoAPIProtocol {
3
4
// This class initializes API to perform network calls.
5
private let api = API()
6
7
func uploadVideo(data: Data) async throws {
8
// The uploadVideo method uses api underwater to make real
network calls.
9
let result = try await api.upload(data)
10
// ... implementation ommitted for demonstration purposes.
11
}
12 }
To instantiate VideoClient in the production environment, we need an instance VideoAPI which
we will use to create VideoClient.
1 let videoAPI = VideoAPI()
2 let videoClient = VideoClient(videoAPI: videoAPI)
4.3.3 Setting up a testing environment
In the testing environment, we introduce a MockVideoAPI which conforms to VideoAPIProtocol
too. But unlike VideoAPI, MockVideoAPI does not depend on API and doesn’t upload videos. It
merely checks whether uploadVideo(data:) is called.
1 final class MockVideoAPI: VideoAPIProtocol {
2
3
// We can add boolean flags or test expectations to verify certain
methods have been triggered.
4
var didUploadVideo = false
5
6
func uploadVideo(data: Data) async throws {
7
didUploadVideo = true
8
// ... implementation ommitted for demonstration purposes.
9
}
10 }
93
Mobile System Design
To instantiate VideoClient in the testing environment, all we need to instantiate is MockVideoAPI
and pass that to VideoClient.
1 let mockVideoAPI = MockVideoAPI()
2 let videoClient = VideoClient(videoAPI: videoAPI)
With this setup, we can use VideoClient with either a VideoAPI or a MockVideoAPI to suit our
environments.
A benefit of testing this way, is that it’s easier to instantiate the class we want to test. In our case, we can
test VideoClient in isolation as a unit. Our setup code is tiny, we merely need to create an instance
of MockVideoAPI and nothing else.
Notice that VideoClient still depends on VideoCompressor. We could state that VideoClient is
not completely isolated. Labeling something as ”completely isolated” becomes a grey area. Alternatively,
the VideoCompressor could have been a single function, method, or a nested type. But because it is
formally its own class, it may feel like a dependency has to be passed in.
Because it doesn’t require external dependencies, we can consider VideoCompressor an intrinsic
dependency of VideoClient that does not need to be mocked.
4.3.4 Downsides of mocking higher in the stack
We just witnessed a common way that developers will isolate their types of unit-testing. Here, we mock
higher in the stack. We introduced a protocol in the layer right below the VideoClient layer.
However, testing higher in the stack comes at a hefty price.
The integration between VideoAPI and VideoClient is completely missing in our test-suite!
The VideoClient will always have a perfectly controlled networking environment in their unit-tests,
since it doesn’t depend on VideoAPI in its tests.
We may have separate tests for VideoClient and VideoAPI. But during unit-tests, we can’t be
confident that the integration between VideoClient and VideoAPI is working well.
Individually, these classes may be well-tested, but subtle bugs can sneak in. Maybe VideoClient
calls the upload method twice on VideoAPI, or perhaps it returns on the wrong thread. Or maybe
VideoClient returns a success state before the upload is fully complete – which is a scenario that is
quite easy to run into when uploading a large file into smaller, resumable, chunks.
These bugs may not happen initially because, as developers, we tend to verify a new feature works
well. But these bugs can more easily sneak in over time during maintenance. We increase the risk of
these bugs because we aren’t catching it with a (unit)test.
94
Mobile System Design
In order to be confident about the integration, we have to resort to other tactics. Such as manually
verifying this feature works for every release. Or maybe spread internal builds around and hope people
catch bugs. Others may opt for integration tests, where the team will invest in writing tests where the
app will make real network calls to verify the code works.
Either way, these solutions require a lot of extra work just to make sure that VideoClient works well
with VideoAPI, and that’s only for these two classes!
Because we mocked higher-up in the stack, we’re making it harder and more time consuming to ensure
our integration works as intended. We are pushing the testing to a phase closer to release, such as
manual checks before release. Which, as we’ve covered in the beginning, is detrimental for mobile
development.
4.4 Mocking lower in the stack
Let’s test VideoClient again, but this time we’ll use the system-wide approach. This time, instead of
trying to mock as high as possible in the stack, we’ll try to mock as low as possible.
Looking at our stack, we can see that API is the lowest class that we can mock out.
Previously, we mocked out one layer below VideoClient, now we will mock out two layers lower.
By mocking out API, the VideoClient class will now depend on VideoAPI during tests, unlike before.
This means our unit-tests more closely resemble what we are actually shipping to production.
During unit-tests, however, VideoAPI will still have to depend on a mock variant of API. So there
might be some lingering bugs in the integration between VideoAPI and API that we can’t find during
95
Mobile System Design
the unit-testing phase.
That doesn’t necessarily mean we should mock out the entire class. Let’s see if we can break API down
to test even deeper for a larger testing-surface.
4.4.1 Finding the smallest surface to mock out
First, let’s go back and think about why we want to mock API. The answer is to ensure we don’t make
network calls to servers during unit-tests.
API is in charge of network calls, sure, but API does more than that.
An API class would mostly consists out of business-specific code – such as token management, parsing,
validation, building network requests, and so on. The other 10% might be making the actual network
call using a network dependency.
Instead of mocking API entirely, we’ll mock only out the network dependency. To achieve this, we’ll
decompose API and extract this piece of functionality.
For iOS, we depend on URLSession – supplied by Apple’s Foundation framework – to make network
calls, we’ll make that an external dependency for API that we can swap out.
After we’re done, we can confirm that VideoAPI will depend on the real API in our tests, by looking at
the graph.
96
Mobile System Design
4.4.2 Setting up the API dependency
To make all of this work, we can ensure API to accepts an interface (protocol). Except unlike before,
this protocol lives much deeper in the stack.
Let’s name ours URLSessionProtocol, allowing us to pass either a real URLSession or
MockURLSession to API.
Below we see the URLSessionProtocol definition and how API accepts it as an external dependency.
NOTE: This protocol only has one method (the same that URLSession uses). But in practice this protocol
can be expanded.
1 // We introduce a new protocol
2 protocol URLSessionProtocol {
3
func data(for request: URLRequest) async throws -> (Data,
URLResponse)
4 }
5
6 // The API class uses this protocol
7 final class API {
8
private let urlSession: URLSessionProtocol
9
10
init(urlSession: URLSessionProtocol) {
11
self.urlSession = urlSession
12
}
13
14
// ... snip
15 }
Next, we need to ensure that URLSession conforms to the newly introduced URLSessionProtocol,
so that we can pass any implementation to API.
In Swift we can make URLSession conform to URLSessionProtocol by writing an extension.
1 extension URLSession: URLSessionProtocol {}
The next change we need to make is ensuring that we can pass API to VideoAPI.
To support this, VideoAPI must now accept an API dependency. Let’s update it so it accepts a passed
API via the initializer.
1 final class VideoAPI: VideoAPIProtocol {
2
3
// This class now depends on API from the outside.
4
private let api: API
5
6
init(api: API) {
97
Mobile System Design
7
8
9
10
11
12 }
}
self.api = api
//... snip
Our code is ready to set up VideoClient for production. We will pass an instance of URLSession to
API, after which we can instantiate the rest.
1
2
3
4
5
// We'll use the URLSession.shared singleton provided by URLSession.
let urlSession = URLSession.shared
let api = API(network: urlSession)
let videoAPI = VideoAPI(api: api)
let videoClient = VideoClient(videoAPI: videoAPI)
To set up VideoClient for testing, we will use a similar method. But, instead of passing URLSession
to API, we will pass a custom MockURLSession that we’ll introduce, which we’ll also conform to
URLSessionProtocol.
1
2
3
4
5
6
7
8
9
final class MockURLSession: URLSessionProtocol {
// .. Implementation omitted
}
// Setting up VideoClient for testing
let mockURLSession = MockURLSession()
let api = API(network: mockURLSession)
let videoAPI = VideoAPI(api: api)
let videoClient = VideoClient(videoAPI: videoAPI)
Now all code will compile again.
As a result, we can use the real API in its unit-tests for VideoAPI and VideoClient. Not only that, API
will still be 90% production-code, only 10% will be code that we mocked out – which is a URLSession
mock. As a bonus, VideoClient will indirectly use more production code in its tests as well.
This will give us more safety guarantees and reduces the risk we take.
4.4.3 Trade-offs
One annoying trade-off is that the protocol now has to mirror all URLSession methods that we want
to use. Because API only depends on the interface and can’t reach the methods on URLSession
anymore.
Luckily in this case, specifically in iOS, we can avoid having to mirror URLSession with a protocol.
Because we can mock even deeper since URLSession offers its own mocking capabilities in the
98
Mobile System Design
shape of URLProtocol. By using the mocking methods from URLSession, we can let API keep using
URLSession, and it avoids the need to introduce a URLSessionProtocol.
But, we’ll stop here to keep this chapter more platform-agnostic. Not to mention, not all solutions
come with their own mocking capabilities.
Another price we pay is that we require more setup code. Notice that to set up the VideoClient, we
now need four lines of code. This boilerplate may make developers apprehensive of this approach.
We’ll cover that shortly, too.
To round up this method of testing, let’s cover how we can apply a similar approach to
VideoCompressor.
4.4.4 Mocking for expensive operations
Other than networking, there might be more reasons to mock out code. Alternatively, we can mock
with closures instead of interfaces.
Let’s consider another example; Compressing videos can be time-consuming. Using system-wide
testing, VideoClient would always depend on a real VideoCompressor during tests. However, it
wouldn’t make a lot of sense to compress videos in all of our VideoClient tests. We can balance it
out and choose to compress videos in a few tests only. Otherwise the time to run our tests might bump
from minutes to hours.
Using the same technique that we applied to API, we can decompose VideoCompressor similarly to
get a larger testing-surface.
The VideoCompressor does a bunch of things. For example, it may need to use disk-caching and
multithreading to handle large files. But that’s not the slowest part. The slowest part is the compression
itself, which is an algorithm that turns bytes into a new set of bytes.
99
Mobile System Design
By decomposing VideoCompressor, we get the following components:
The compression-algorithm is the part that’s slow. So that’s the tiniest component we can swap out in
our tests while keeping everything else the same.
Previously we used a protocol for that, but alternatively we can use closures, too. Let’s go over how
that will work.
Compressing the video is a matter of data in, data out.
In code, we can express that as a function definition.
1 let compressionAlgorithm: (Data) -> Data
We can add a body to the function with a real algorithm. For demonstration purposes we will leave out
the algorithm – and admittedly because I have little knowledge about video compression.
In production, we can pass the algorithm when initializing VideoCompressor.
1 let compressionAlgorithm: (Data) -> Data = { data in
2
// Perform expensive calculations with data.
3
4
// Algorithm omitted for demonstration purposes.
5
let compressedData = ...
6
return compressedData
7
8
// We can instantiate VideoCompressor with a compressionAlgorithm.
9
let videoCompressor = VideoCompressor(algorithm:
compressionAlgorithm)
10 }
Conversely, for testing, we can pass an algorithm that’s lightweight.
100
Mobile System Design
In the testing example below, we pass an algorithm that ignores the data that’s passed in, and returns
the data as is.
1 // The mock algorithm doesn't do anything, it's an empty closure that
ignores the data that's passed in.
2 let mockCompressionAlgorithm: (Data) -> Data = { data in data }
3 let videoCompressor = VideoCompressor(algorithm:
mockCompressionAlgorithm)
4.4.5 Accepting a closure as a dependency
To pass a function to VideoCompressor, we do not need a protocol. We are swapping out a single
function, hence why we can use a closure, instead.
In code we can express that as follows when defining VideoCompressor; We define an algorithm
property and we set it via the initializer. Once we compress a video using the compress(videoData:)
method, we use the algorithm and return the compressed data.
1 final class VideoCompressor {
2
3
// We store an algorithm.
4
private let algorithm: @escaping (Data) -> Data
5
6
// We can pass an algorithm in the initializer.
7
init(algorithm: @escaping (Data) -> Data) {
8
self.algorithm = algorithm
9
}
10
11
// To compress a video, we can pass in data.
12
func compress(videoData: Data) -> Data {
13
// We run the data through the algorithm and return the result.
14
// Not depicted: Disk caching and multithreading
15
let compressedData = algorithm(videoData)
16
return compressedData
17
}
18
19
// ... Rest omitted for demonstration purposes.
20 }
NOTE: Because we are storing a closure, we need to mark it as @escaping. This communicates that the
closure is stored, and keeps references in memory.
101
Mobile System Design
4.4.6 The end result
By testing VideoClient, we now cover a substantially larger testing surface in our video client than
before.
We are testing 90% of the entire domain by mocking very little. This allows us to get more safety
guarantees before merging code, and all our subsequent tests can verify that VideoClient works
correctly.
To balance it out, we can choose to use the “production” algorithm in only a few tests. This way, we
can verify everything works as intended, and we can use the lightweight algorithm for the rest of our
test-suite. This ensures our test-suite runs faster.
4.5 Making system-wide testing smoother
Let’s consider the downsides of the system-wide testing-approach.
Instead of isolated unit tests, we have been writing system tests that go beyond tiny units.
We may be told or believe that it’s the industry standard to only tests tiny units in isolation – it’s in the
name after all. We may feel that testing a unit with its real dependencies is a bad idea. It’s much easier
to mock our dependencies out after all.
But let’s reconsider the benefits; We get more quality guarantees during the development phase in the
shape of unit-tests. With a regular Continuous Integration set up, these tests will always run before
anyone in your team merges code.
That means that if someone forgets to verify video uploading manually, we still have these unit-tests as
a safety-net. Whereas before, we would have to resort to other means later in the development stage,
such as manual checks, or worse, shipping bugs.
Let’s do some introspection and label all problems and objections. Let’s define what is really stopping
us from testing while mocking as least as possible, after which we’ll come up with a plan to mitigate
some of these issues.
Here’s the list of common barriers we experience during unit-testing:
1. It’s difficult to test I/O properly, such as networking and file storage.
2. It’s slower to use the real disk during tests.
3. It’s difficult and often impossible to unit-test the outside-world; Such UI, biometrics, or how the
app responds to being backgrounded.
4. It’s harder and takes more work to set up tests when everything has to be initialized. Mocking
everything is more convenient.
102
Mobile System Design
5. There might be difficult-to-test components for various reasons. Maybe we can’t test code we
don’t own – such as closed-source software like SDKs from a vendor.
6. We might have slow-running code, such as expensive operations like video-encoding or imageprocessing – thus slowing down test-suites.
7. If we test with production code instead of mocks, our tests will be slower to compile and run.
Now that we’ve identified common issues, let’s see what we can do to identify, minimize, or even solve
certain issues.
4.5.1 Dealing with boilerplate
One problem with the system-wide testing method is that it creates more boilerplate.
Consider setting up the VideoClient, by doing so, we now need to inject some sort of URLSession
to API, and a compression algorithm to VideoCompressor, after which we can set up the entire
hierarchy.
1 // We set up a testing algorithm.
2 let mockCompressionAlgorithm: (Data) -> Data = { data in data }
3 let videoCompressor = VideoCompressor(algorithm:
mockCompressionAlgorithm)
4
5 // We set up a mock URLSession
6 let mockURLSession = MockURLSession()
7 let api = API(network: mockURLSession)
8 let videoAPI = VideoAPI(api: api)
9
10 // Only after all dependencies are created, can we initialize
VideoClient.
11 let videoClient = VideoClient(videoAPI: videoAPI, videoCompressor:
VideoCompressor)
To create a VideoClient, we now need five extra lines of code before we can initialize it, and that’s
only a small part of our entire app. If we were to set up an entire app, we would have dozens of lines of
code this way.
You may object to having to set up the entire stack just to test VideoClient. Performing a lot of
ceremony just to test one thing is a fair point. But with a little work, we can reduce the effort we need
to put in.
4.5.2 Reducing boilerplate in production code
One way to reduce the boilerplate is to offer default values. For instance, we can state that
VideoCompressor works with a normal algorithm by default. But we pass in the mock algorithm
103
Mobile System Design
during tests, only.
In Swift, we can achieve this by offering default values in the initializer. Such as URLSession.shared
for API, allowing us to initialize API without parameters.
This means that we set up VideoAPI with a default API(). Now we can instantiate VideoAPI without
parameters, if we so please.
Second, we can define certain algorithms, such as a high compression algorithm, and make that the
default for VideoCompressor.
Below we’ll add default initializers to API, VideoAPI, and VideoCompressor, to ensure that we can
instantiate them for production without passing any values.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
final class API {
private let urlSession: URLSessionProtocol
init(urlSession: URLSessionProtocol = URLSession.shared) {
self.urlSession = urlSession
}
}
final class VideoAPI: VideoAPIProtocol {
private let api: API
// We now offer a default API instance
init(api: API = API()) {
self.api = api
}
//... snip
}
final class VideoCompressor {
30
31
32
33
34 }
104
// ... snip
// We store an algorithm.
let algorithm: @escaping (Data) -> Data
// We use a high compression algorithm in the initializer by
default.
init(algorithm: @escaping (Data) -> Data = Algorithm.
highCompression) {
self.algorithm = algorithm
}
Mobile System Design
Next, we will offer default values to the initializer of VideoClient. We achieve this by utilizing the
default values for videoAPI and videoCompressor that we just defined.
1 final class VideoClient {
2
3
// Now videoAPI and videoCompressor are passed in.
4
private let videoAPI: VideoAPI
5
private let videoCompressor: VideoCompressor
6
7
// Both videoAPI and videoCompressor will now have a default value.
8
init(videoAPI: VideoAPI = VideoAPI(), videoCompressor:
VideoCompressor = VideoCompressor()) {
9
self.videoAPI = videoAPI
10
self.videoCompressor = videoCompressor
11
}
12
13
// ... implementation omitted
14
15 }
Finally, we can set up VideoClient again without having to pass parameters.
1 let videoClient = VideoClient()
Depending on your programming environment, you can reduce the entire setup code to a single
one-liner.
One downside is that the production code is implicit. Meaning, that if we don’t know about these dependencies, and we “just” create a VideoClient() during tests, it will implicitly talk to the production
servers.
Or during testing, someone might inject a mockable variant in some classes, but forget one, still
triggering production network calls in the test-suite.
This approach requires some awareness of the team.
Alternatively, if your programming environment permits, you can opt to offer these initializers only in
production code to avoid this problem.
4.5.3 Reducing boilerplate in tests
We just made sure that VideoClient and all its (in)direct dependencies have default values. These
are values used for production.
That means we still have boilerplate code during tests, because we can’t rely on default production
values for tests.
105
Mobile System Design
We can use a similar approach for testing. Alternatively, we can define default values that are easy to
use and reuse across all tests using factories.
In the testing environment, we could, for instance, add static defaults, giving us an instance that does
not require parameters. In Swift, we can achieve this with an extension on the types, and adding a
static property.
NOTE: We need to mark default as 'default' because default is a reserved keyword.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extension API {
static let 'default' = API(urlSession: URLSession.shared)
}
extension VideoAPI {
static let 'default' = VideoAPI(api: API.default)
}
extension VideoCompressor {
static let 'default' = VideoCompressor(algorithm: Algorithm.simple)
}
extension VideoClient {
static let 'default' = VideoClient(videoAPI: VideoAPI.default,
videoCompressor: VideoCompressor.default)
16 }
To initialize a VideoClient in a testing environment, all we have to use is VideoClient.default.
It’s easy to set up, and it can be considered boilerplate by itself. But having a centralized location that
contains all default values helps remove a lot of boilerplate in the rest of the test-suite.
It’s one approach, and you can use other techniques to make it less of a hassle; Such as having code
generated if your programming environment allows. Or using static functions to offer defaults that are
more configurable.
4.5.4 Dealing with slower-running tests
Another common objection is that tests will run slower because all components run together. Imagine
only testing VideoClient in isolation versus maybe 30 classes underneath with slower / more complex
code.
Instead of relying on a larger number of fast tests that might miss issues until after code merging, it’s
worth considering the idea of having fewer, albeit slower, tests that provide the assurance that the
entire system functions as intended.
106
Mobile System Design
Another compromise solution is where you mock out slow or difficult parts by default. Yet, you keep a
test where everything is working together with real types. This way you get the speed and the safety
that the system works well as a whole.
Another common objection is that tests will run slower since our code depends on more productioncode.
But even if tests run slower, the entire release process might be faster. Since now, we don’t have to rely
as much on manual testing, and we can depend less on integration tests, since we are already covering
a system-wide test.
You can save time writing tests. For instance, by testing VideoClient, we indirectly test VideoAPI,
API, and VideoCompressor.
Another approach is to run the slow suite only periodically. This means you have a regular “quick
mocked” test-suite for daily development, and a nightly CI job that can run the “heavy” suite for extra
checks.
4.5.5 Consider using file systems in your tests
One aversion that developers sometimes have is that they don’t want to use the file system during
tests. As a result, they mock out file systems with a memory store.
It makes sense, disks are less reliable; They can be full, give errors, it’s a bit slower, tests need to clean
up their local data, and so on.
But consider its value. For instance, if you write a library that uses local disk caching, then it might be
good to know that some platforms automatically append a trailing slash “/“ to paths, and some don’t.
This can cause issues where the code that “always just worked” suddenly starts introducing strange
bugs related to folders.
Don’t assume that your tests will be slower. Even if they are, the guarantee that code works can be
worth the price of speed.
4.5.6 Dealing with untestable code
Another downside in practice is that some classes are just not designed to be unit-tested easily. It
would require substantial effort to make it testable.
In this book, we are starting from scratch, a nice greenfield project. But in real-life you have to use dirty
untestable code that depends a lot on state and I/O. You may have code that depends on legacy code,
and so on. Maybe it’s all a big part of spaghetti-code where everything depends on everything.
107
Mobile System Design
In that scenario, you can’t always take this system-wide approach. But you can still decide to mock
out bits and pieces surgically using fewer interfaces. Or face the pain and make difficult components
testable to get the benefits we covered.
In new features that you’ll write you have more agency to design it however you want. In that case,
consider this approach and see if it works for you.
4.5.7 Distance between tests and code
By testing VideoClient, we are indirectly testing VideoAPI, VideoCompressor, and API. If you
were to say, fix a bug in API, you are updating tests in VideoAPI to catch this bug.
This creates some distance where it may seem that API isn’t tested for the bug. Yet, it is tested, but
indirectly indirectly by VideoAPI.
This creates a disconnect where tests related to a class aren’t available near the class. This creates
an indirect dependency and can harm portability. In essence, if you want to extract API but not
VideoClient, then API loses some related tests.
In the upcoming domain-testing chapter. we will cover this topic more in-depth.
In other cases, you can’t avoid having to dig deep down to fix a bug. We can’t solve everything from
testing a type much higher-up. Sometimes you do need to dig deep and test a unit in isolation. The
system-wide approach isn’t a silver bullet.
Try to find a good balance what works for you.
4.6 What is a unit anyway?
If we are not testing code in isolation, we go against the entire point of testing units. After all, they are
called “unit” tests.
But who defines these so-called “units” in unit-testing, anyway? There is no official guide. Is there
some secret committee that says “this code is a unit, but if calls a method from another class then it
isn’t.“?
Sometimes it can be quite clear; We might write the tiniest element to test. Such as a tiny, pure,
function. We can call that a unit.
But what if that function calls other functions? Is this still a unit? It’s using other functions, after all.
What if we add those functions to a class and call it a method? We can consider that class a “unit”.
108
Mobile System Design
We can write a god class that does everything and call that a unit. It might be a fat unit, but it’s still a
single unit.
We can break up a struct into separate enums, and smear them out over files, and we can call those
units. Then we can take all those “units” and stick them into a larger component as part of an app.
From the app’s perspective, that component is one “unit”.
What defines a unit quickly becomes blurry.
One way to think of it is that the definition of a unit depends on the context of how it’s used. For
instance, from the app’s perspective Course is one component or one unit. But from the perspective
of Course, CourseAPI or TODOList is one unit.
Software relies heavily on composing. Whether that’s slapping a few functions on a struct or making
functions call other functions. We can break down our program and witness how it’s all composed of
other functions; all the way down until we reach assembly code, ones and zeros, and the quantum
realm.
We can convince ourselves something is or isn’t a unit at arbitrary levels. We can argue during pull
requests whether we’re breaking the “single response principle” (which is subjective). But let’s not get
too religious about testing only units in isolation.
NOTE: To learn more about how the Single Responsibility Principle is subjective, please refer to the book
‘SOLID is not solid‘ on https://solid-is-not-solid.com by David Bryant Copeland.
My point is: Let’s not blindly focus on testing “units” and let’s not draw arbitrary lines to define a
unit. Instead, think about what you need to test to ensure you don’t ship bugs. That’s it.
I hope we can agree that there is some gray area here where we decide what “units” we want to test.
Use that "unit" works best for you.
109
Mobile System Design
4.7 What we covered
In this chapter, we covered:
• Releasing and damage control is more limiting and difficult in a mobile environment.
• Damage prevention place a big role in mobile app development because we can’t always quickly
release.
• Testing small units in isolation introduces a risk; We don’t always know how well things work
together until after you merge your code.
• That we can get more quality guarantees by testing a codebase as a system, as opposed to
smaller units.
• We can test code as a system by mocking deeper in the stack.
• To make less code mockable, we can use closures instead of interfaces.
• A "unit" isn’t defined. You decide on the unit that works for you.
• We can indirectly test lower-level components by testing higher up in the stack.
Downsides of system-wide testing
• System-wide testing requires more boilerplate. You can minimize the boilerplate using default
parameters and default properties.
• System-wide testing can make test-suites slower and compile times longer. You can choose to
run slow tests periodically or separately.
• System-wide testing happens at a higher abstraction. This creates a distance between the code
you’re "actually" testing, and the testing code you’re writing.
• Some code doesn’t lend itself to system-wide testing and need to be mocked out at units anyway.
One such example is closed source software.
110
5 Cross-domain testing; Testing more with less
effort
In this chapter
• Reasoning about testing across domains
• A plan of which domains are most valuable to test
• Where to get the most impact from writing tests
• Team dynamics in regards to testing
• Reasoning about which code is most volatile in early stage of development
In the previous chapter, we’ve covered how to test on a system-level. We were able to test the most, by
mocking as low as possible in the stack.
We zoomed in to a detailed code-level to achieve this. But we are not done, we still have an entirely
untested Course feature.
Developers that are pressured or want to move really fast might skip writing tests altogether. This can
be an excellent decision for small teams or fast-moving startups. But skipping tests is detrimental for a
codebase. You would have to rely on manual checks – or worse, negative customer feedback – to make
sure your code works well.
On the other hand, some developers may have a “everything must be tested” mindset, even private
functions, which can seriously slow teams down. This mindset gets in the way of keeping momentum
and robs of us the ability to keep delivering and iterating.
When looking at all the components and domains we introduced, it might not be clear where you
should invest your time and energy when writing tests.
Keeping momentum is especially important for new features and early stages of software, since we
iterate a lot more with new code. One goal is to avoid throwing away code that we spend time on to
test.
NOTE: Throwing away code is common in this early stage. Which is why, in this book, we avoid the
Test-Driven Development approach. Because why test everything, if you’re going to throw it away shortly
after?
111
Mobile System Design
Instead of taking a look at testing on a class-level, let’s zoom out and consider testing on a domain
level. Because by doing so, you can save ourselves lots of time writing tests.
5.1 Avoiding a redundant testing surface
In the last chapter, we’ve covered that by testing the highest class, we can indirectly test the classes
below. Let’s expand on this idea.
Let’s assume our stance is that to “test properly” we need to test every domain initially. We’ll keep
using the Course landscape as an example.
The following scenario assumes that we applied system-wide testing, as covered by the previous
chapter. We only mock out API ’s network calls. But other than that, the domains rely on real code —
not mocked code — during tests.
For instance, during tests, Course uses the real TODOList, not a mock. TODOList uses the real API,
which only mocks its network.
Let’s also assume that we individually wrote tests for every domain.
NOTE: For simplicity, we will continue without the VideoClient feature.
Looking at the lowest layer, API is tested once directly, meaning that we wrote tests solely to test API.
Yet indirectly, API is tested no less than four times, because there are tests for Course, TODOList,
Calendar and Tutor.
Testing the same component five times seems like a poor use of our time that could’ve been spent
elsewhere – such as iterating on our feature or grabbing a second lunch.
Components in the middle are tested twice. For instance, TODOList is directly tested by its own tests,
but indirectly it’s tested by Course ’s tests.
If we could only test one domain, then the most efficient use of our time is to test Course. By doing so,
we will test the entire landscape.
112
Mobile System Design
This benefit where we test everything by testing a single domain, is possible when applying on systemwide testing.
Eventually, we will want to test the other domains directly, too. But since we are delivering new code,
we’ll prioritize Course to make best use of our time at this stage.
5.2 The most important domain lives up top
We just established that by testing Course we test all domains, and coincidentally, it’s also the most
important domain. Let’s confirm why the highest domain is also the most important domain at this
stage of development.
Ideally, we have everything perfectly tested. But let’s be smart about this. Ask yourself “If we could
only test one thing, what would we test?”.
In the Holistic Driven Development chapter, we learned that Course (up top) is the most valuable
domain to work on first.
As we established before, the Course domain is what we’ll use to support the UI feature we’re building.
Course has all the logic we need; Handling TODO items, loading tutor data, dealing with scheduling,
consolidating all the data loading, offline storage, and so on.
All these functionalities are unit-testable. The ~UI ~domain can consume it to populate the screen with
little effort.
From the UI perspective, it doesn’t matter whether Course uses dirty hacks underwater or has the
cleanest architecture ever built. Only maintainers (and bug fixers) should worry about the internals.
With this in mind, let’s consider Course as the highest priority for both delivering and testing. That
113
Mobile System Design
doesn’t imply we shouldn’t test the other domains. It implies that Course is best to focus on first for
testing.
Whenever you’re wondering what to test first, try to think of the highest-placed element in the stack.
Because you’ll (in)directly test everything below.
NOTE: The UI domain is placed even higher than the Course domain, indicating that it can be more
important to test. In a way, this is true. But, testing UI is its own beast, and it comes with its own limitations
and trade-offs that we’ll cover in a later chapter.
5.3 Be aware of volatile code
Assuming we don’t stub and mock code out too much, by directly testing the top-level domain, we
sufficiently test lower-level domains. This comes with a perk that we can exploit.
At the early stages of feature development, code is more volatile. We’re still figuring out details,
changing our code up a lot, experimenting, and so on.
Foundational code (the lower domains) is less prone to change compared to features up-top.
In our case, for example, API would be least prone to change since it contains network requests that
don’t change often. Whereas business requirements (closer to the UI layer) change more often; For
this project, that would mean that Course would have more changes because we’d add new features
and so on.
However, when making something new, the inverse is more likely; The lower-level domains are
more prone to changes.
The architecture is our own invention — unlike a direct business requirement. Because we’re still
maturing it, expect the foundational domains (lower in the graph) to be more volatile at this stage.
Conversely, normally we would iterate and update customer-facing features regularly. But, because we
are still making the initial version, it’s more likely that the UI is more fixed at this stage. Only once you
release a feature (either internally or publically), expect the UI domain to update a lot more often.
Because once you show the UI to others, that’s when people finally see the designs come alive. People
form new and better ideas of how features should behave. After releasing to the public, your team
gathers feedback and data from users and customers, again triggering more UI and feature changes.
For us, at this early stage, it’s better to start testing Course, since testing lower-level domains, such as
API or TODOList will risk more changes and therefore we risk losing time by throwing away tests.
After time progresses, lower-level domains should definitely be tested too – it is foundational code
after all – but we can consider the top-level domain (Course) the highest priority at this stage.
114
Mobile System Design
We can make it our advantage that we’re not testing the lower-level domains. If we were to hyper-fixate
on testing the lowest-level components at this stage, we’d just have to keep updating the tests. They’d
be functioning as little anchors or “change tests” that will slow us down.
5.4 Reason about classes the same way as you do with domains
We are reasoning about which domains to test first. By testing domains directly up-top, we are indirectly
testing domains underwater.
On a class-level view, we could take a similar stance. We could say “By testing public methods, we are
indirectly testing private methods.”
For instance, we should test CourseAPI by calling its public or internal – methods which are accessible
from the outside, but we won’t have to test its private methods, which are only visible by CourseAPI
itself.
NOTE: Internal methods are accessible to other locations – such as classes – within the same module or
app.
As long as we make sure the public API works as intended, then we don’t need to worry about what
happens underwater and whether CourseAPI uses zero or 500 private methods.
By not testing private methods, you gain two benefits.
First, you’d have to write fewer tests to be ensure private methods work. They are indirectly tested,
after all.
Second, you won’t end up with “change tests”, where making a change to a private method causes you
to update tests .
By testing internal or public methods, we are free to change the internals of CourseAPI with no
secondary repercussions where we have to update tons of testing code. We are safe as long as we
make sure the public API of Course remains stable by testing its API.
5.5 Test the foundational domains as a next priority
Another takeaway is that we have to be smart about which domains we should test first. As we’ve
covered, we can test more by writing less code, as long as we focus on testing domains higher-up in the
landscape. Which would be Course in our case.
115
Mobile System Design
But over time, ideally we should end up with every domain tested. This will bring the benefit that a
domain is more self-sufficient. Which will make it easier to make them self-contained and even extract
them out as libraries, frameworks, modules, or packages.
In day-to-day work, think about which domains are best to test. Will you start up-high, or maybe lower?
Consider what you can cover by having higher domains indirectly test your foundational domains.
It also heavily depends on your organization and capacity. For instance, if you’re a vendor that ships
an SDK, then its public API is most important to test.
But if you own ten modules spread over two teams, then perhaps you can guarantee stability by testing
both high and lower-level domains.
5.6 Trade-offs when testing lower domains later
Testing foundational components is a good thing. But, the nuance is to test them at a good time.
The risk of our approach is that we rely on the Course feature to test the network layer (and other
things).
Imagine we receive new requirements: we don’t need network calls anymore for Course, and we keep
it local (offline) only. We can refactor the tests and Course so they don’t use API anymore. Now nothing
is testing the API layer.
But maybe we need the API layer for other features. So we hope that other features properly test the
API layer indirectly.
Whereas, if we have tests accompanying API, we can move it to features, folders, or even entirely
different modules in their own repository. The tests will always be there, accompanying API. It gives
us a lot of agility.
It will mature the networking layer and make it independent and portable.
Adding unit tests makes a lot of sense. Be mindful of the order in which you add them. It might not be
the top priority at this early stage. But we should definitely decide to offer it shortly after.
5.7 Domains in the context of a larger app
We covered how the highest-level domain is the most important domain. But let’s briefly cover how
this translates to a larger context of an entire app with other teams.
In our context, we are designing something new, and in our fictional team, we own the domains in the
landscape graph.
116
Mobile System Design
Since our team owns this part, we will select the highest domain – which would be Course – and
decide to test that domain first. We covered testing considerations for the lower domains, such as
TODOList and API.
But what about the other direction? Let’s think about what would live above Course besides UI.
In reality, Course isn’t the highest domain. There is UI above that. But, if we were to zoom out to
depict an entire app, we will end up with a large graph depicting the entire application.
We can imagine the application as a giant graph of nodes and hierarchies all intertwined and connected,
just like our landscape graph, but much bigger.
As a simplified version of an entire app, we can imagine how Course lives besides other domains such
as Onboarding and Login. Course itself could indirectly belong to a tab bar via TabBarManager.
Directly, Course might be part of a Course Marketplace where a user can browse and purchase
courses.
NOTE: We marked these new domains with a dashed outline because we have no idea (yet) how they
117
Mobile System Design
would work. This is a simplified view, in reality, there would be more lines and more domains.
In theory, we could decide to go test a domain higher, above Course. This way, Course we can
indirectly test Course too, saving us time to test it directly.
For instance, let’s say another team owns Course Marketplace, a new domain in this graph. If this
team tests Course Marketplace without mocking out Course, then this team will indirectly verify
that Course works properly. We might state that it might not be as important to fully test Course
since Course Marketplace will handle a bunch of checks related to Course.
This is a tactic you can use. However, when moving upward and outward, team-dynamics can come
into play, where we’d rely on different teams to test our code. That can’t always be a good recipe since
it adds communication-overhead and removes autonomy from our team – we rely on others to make
sure our code works after all.
A recommendation is to first test the highest-domain (node) that your team is responsible for.
After a while, it’s beneficial to have other teams test the integration of your app as a safety net. However,
our responsibility is to deliver Course, so let’s focus on testing that.
5.7.1 When we are working in the "highest" domain
As a thought-exercise let’s think about the other direction: What if we are the team with the highest
domain? Let’s imagine that we own Course, but three other teams are each separately delivering API,
TODOList, and Calendar which live below Course.
In that situation, we have enough capacity to write tests for all these domains. We can write tests
for Course and we should expect that each individual team is responsible for the quality of their
respective TODOList, Calendar, and API domains by writing tests for their them.
With team-dynamics coming into play, we depend a lot on the other teams, yet we are still responsible
for Course. Meaning that even though each team tests their own domain, it’s still worthwhile to
indirectly test all these domains by testing Course – which allows us to test the integration where all
domains come together. Because every domain may work great in isolation, but they might not when
combined together into Course.
Keep in mind that bugs can slip through the crack anywhere, too. For instance, let’s imagine that API
subtly changes its behavior — maybe it returns data on a different thread after an update. A change
like that might subtly break Course.
But, by testing Course, we might detect this change and protect ourselves from bugs.
With system-wide testing, the tests of a domain higher-up can act as a safety-net for domains lower in
the graph.
118
Mobile System Design
NOTE: A fix for this issue should not be in Course, the culprit is in API where the fix should be made; If
not, Course could end up with workarounds and hacks to compensate for other domain’s issues.
5.7.2 Domains are only responsible for their own functionality
One risk when using sub-domains to test your own domain is that your domain may test exactly what
a sub-domain is already testing.
A team might end up with redundant tests and blurry responsibilities where we have to ask ourselves
“what is this domain responsible for?”
For instance, if Store is making sure data is properly stored on the disk, then Course doesn’t need to
test that Store is properly storing on the disk, that’s ~Store~’s job.
The owners of Course must be able to trust that Store is doing a good job. If not, then the fixes should
go to Store, it shouldn’t result in more workarounds and tests in Course.
Here, Course will use Store to run its tests, but Course doesn’t need to verify whether Store wrote
to the disk. Course can assume that Store knows how to do its job.
What Course should be testing for is its own functionality and requirements.
For example, we need to ensure that Course does not load the data from the network twice, which is
a business requirement. How that is achieved – whether Store writes to a disk or not – is an irrelevant
implementation detail to Course.
Another way to think about it: Currently, Store caches course’s data to memory, but let’s assume that
after an update, Store will write to a local database instead. That change shouldn’t affect Course
and its tests.
Course should keep working without needing to update its tests. That’s possible when Course doesn’t
test the internals of Store. Otherwise, you end up with “change tests” where you have to keep updating
Course ’s tests whenever another team updates Store.
As a principle: A domain tests its own requirements. It’s not a domain’s responsibility to test
another domain’s internals.
One caveat here is that Course needs to be aware of networking to ensure that loading a course doesn’t
make too many network calls. But that is acceptable since it’s a requirement of Course itself.
119
Mobile System Design
5.8 What we covered
In this chapter, we covered:
• With system-wide testing, you can (in)directly test all domains by testing the highest domain.
• Avoid testing internal logic of subdomains itself.
• Trust that a subdomain works properly. If not, the fix should be offered to that subdomain.
• You can indirectly test private methods by testing public or internal methods.
• Compared against features, foundational code is more prone to change when it’s new. Over time,
the opposite is true.
• Trust that other domains are well tested, distrust integrations between domains.
120
6 Dependency injection foundations
In this chapter
• Understanding the why of dependency injection on a deeper level
• Trade-offs when relying on third-party frameworks
• Understanding the role of compiler flags
• The negative impact when using interfaces for dependencies and testing
• The role and downsides of singletons
• When singletons make sense
Dependency Injection. Also known as "just passing values around".
Now that we’ve covered testing, we can’t avoid Dependency Injection; Especially once we apply mocks
and passing them through various types.
But that’s not the sole reason you may need Dependency Injection. Another reason is when you want
to pass down components shared by multiple instances. Or, when you to swap between different
environments, such as connecting to a staging, acceptance, or production, server.
But let’s not make it bigger than it has to be.
Dependency Injection is an expensive term for a cheap concept; It’s a fancy way to describe the act of
passing values around.
It’s a contentious topic, since there are a million different ways to go about them, and most developers
have a preferred way.
It can get tricky, especially on larger codebases, which is why developers are drawn to dependency
libraries like moths to a flame.
The aim of this book is to show you how you can get extremely far with Dependency Injection – from
both small to large codebases – by applying a few common approaches. This delays the need to rely
on strongly opinionated third-party frameworks.
As a bonus, you can apply these approaches to many programming languages.
Before we start an implementation in the next chapter, we’ll cover the foundations. We will go over the
role of dependencies, and why you need them.
121
Mobile System Design
Next, we’ll dive into some common patterns and anti-patterns.
The chapter will share information about interfaces and how they fit the role of testing and dependencies. Then we’ll go over why many interfaces can actually be harmful.
Much of this chapter covers singletons. This book will be opinionated about that. Singletons are
prevalent in mobile apps, but they often bring more harm than good. This chapter shares some
perspective on them so you can understand the trade-offs if you implement one.
But this chapter will give different considerations that fit a mobile context. Such as how they make it
harder to modularize your app, and how thread-safe singletons are not enough to protect thread-safety
in a different context.
Finally, we’ll cover when singletons are a good idea.
This way, during code reviews, you are better armed when a coworker should, or should not, implement
them.
This book aims to equip you with the ability to handle various dependency techniques for different
codebases by the end of these dependency chapters..
6.1 Vanilla code versus third-party frameworks
Even if you love third-party solutions, don’t underestimate the power of boring.
Imagine someone joining your team. If they see values passed around in the codebase, then chances
are that this new team-member can figure out where dependencies come from and how they are
created and passed around.
This might not be the most elegant system, and they may have to dig around in a larger codebase.
However, it’s relatively easy to see where dependencies come from because there is no magic.
In contrast, what if this same person is now being told to learn about a custom Injectable protocol
with its generic type constrainted to <HomeAppContainer>? Or a macro that generates dependencies
at compile-time by a shell script, built by an employee that left the company?
Now, this person needs to invest some time upfront and read some (possibly outdated) documentation
to figure out a relatively basic concept.
NOTE: In this book, we’ll sometimes refer to Dependency Injection as DI for brevity.
There might be good reasons when dependencies become complex in your codebase. Perhaps you work
on a giant codebase, or perhaps your team aligned on a single strategy for all DI to avoid discussions.
But realize there is a cost once your team steps away from “just passing values around”. People need
122
Mobile System Design
to be educated, your project relies on a third-party open source project, documents need to be written,
libraries need to be maintained, and so on.
6.1.1 The cost of third-party solutions
Relying on third-party frameworks is fine, but we should not increase the complexity because we want
to try out a trendy framework. It will bite us later.
Third-party dependencies aren’t cheap. Sure, at first you’ll be faster to implement something readymade, but a third-party solution can slow down your development, too. Be sure to perform your due
diligence by checking if someone is actively maintaining a library. Make sure they’re responsive to new
OS or platform updates, and double-check if there are any serious open issues.
Also, don’t forget that third-party dependencies can increase your app’s build size.
One sleeper issue is that third-party dependencies themselves can depend on third-party solutions. If
one of those so-called transitive dependencies is slow to update, then that indirectly affects your app.
Such as waiting on a transitive dependency to update, so that you can finally use a newest version of
your favorite programming language.
It’s like going to a concert, but your friend’s friend (the driver) is late, causing everyone to be late.
You’re more than welcome to use any custom solution you like. If a third-party library speaks to you,
then use it. Just be aware of the hidden costs it brings and weigh that against the benefits.
Moving on, let’s start by getting a deeper understanding of DI and see what the fuzz is all about.
NOTE: Fun fact, a third-party DI framework is a dependency.
6.2 Why we need Dependency Injection in the first place
Why we need Dependency Injection in the first place
Let’s go back to the absolute basics to make sure we understand each other, almost to an insulting
level of “don’t you know who I am?”. Then we’ll continue from there by throwing increasingly complex
scenarios at our solution.
During the Holistic-Driven Development chapter, we didn’t use a single dependency injection technique.
All the properties were owned by the relevant types. We passed nothing around. Life was simple.
To refresh our memories, let’s look at the Course domain again.
The Course domain depends on TutorAPI, TODOList, Calendar, and Store.
123
Mobile System Design
For simplicity’s sake, we’ll omit Store for now, but we’ll re-introduce that in the upcoming chapter.
We’ve been using domains, but let’s zoom in to a class view so that we can take a deeper look at how
that works on a code level.
Throughout this book, we use ovals to represent domains and rectangles to represent types, such as
classes and interfaces. Notice how CourseAPI (up top) is the class we’ll focus on in this section. The
classes from the other domains have the same name as their respective domain.
Let’s pull up the CourseAPI class again with no DI, and have a look. It instantiates its own dependencies
– as showed by it making an instance of TutorAPI, TODOList, and Calendar.
NOTE: The private keyword ensures others can not access the properties. This ensures that
~CourseAPI~’s interface remains as small as possible, making it easier to maintain.
1 final class CourseAPI {
2
3
// CourseAPI instantiates its own dependencies
4
private let tutorAPI = TutorAPI()
5
private let todoList = TODOList()
6
private let calendar = Calendar()
7
8
init() {
9
// We aren't passing anything to the inititializer.
10
}
11
12
// ... snip
13 }
When designing this the initial program from scratch, we did not need to get these three dependencies
injected. CourseAPI was able to create all the properties it needed.
124
Mobile System Design
But there are reasons we need DI. One obvious reason is testing, another is to support multiple environments.
Let’s go over some scenarios to get a deeper understanding.
6.3 Testing and mocking
Testing is one of the most common reasons people want to inject code. After all, when running tests,
we don’t want our code to connect to (production) servers.
In the previous testing chapters, we mocked the deepest element in the hierarchy to get a larger
testing-surface.
For example, TODOList depends on API, and we can test TODOList and most of API, by mocking out
the network-connection part of the API class.
The network is behind a Network interface, as shown by a dashed border.
NOTE: For domains, a dashed border shows “not fully figured out yet”. But, for types – as shown by
rectangles – we use a dashed border to show that it’s an interface.
To zoom out a little, TutorAPI, TODOList, and Calendar all use API under the hood.
125
Mobile System Design
As we covered in the system-wide testing chapter, by mocking out the network-connection aspect of
API, we’ll be able to test most of API, and all the domains above it. Namely TODOList, Calendar,
and TutorAPI with Course on top.
Not only will we be able to test the entire hierarchy this way, we can easily swap out environments. We
can easily connect to different servers, such as a staging, acceptance, and production servers.
6.3.1 Dependency injection, testing, and interfaces
Dependency injection, testing, and interfaces often go hand in hand.
However, when you’re eager to introduce interfaces for a dependency or for testing, keep in mind that
there is a hidden cost associated when introducing them.
For example, if instead of working with a TutorAPI, we could decide to swap it out with a
TutorAPIProtocol. Then in debug or production builds, we’ll use TutorAPI and in testing builds,
we’ll use a TutorAPIMock type.
However, this new interface does nothing to help production code, it’s there to make our testing lives
easier. We introduce a new inface -— thus raising complexity – and open up CourseAPI to accept
custom TutorAPIProtocol implementations.
126
Mobile System Design
If we were to apply this mindset over time, these testing interfaces can slowly creep into our codebase,
all across the stack.
As a result, we end up with an increased number of testing-holes in our codebase.
It’s not a problem if it happens here and there, and certainly not in this single use-case. But if we keep
swapping all dependencies with interfaces, then our codebase starts to resemble a Swiss cheese.
To avoid a swiss cheese codebase, we’ll surgically introduce interfaces instead of sprinkling them
around.
127
Mobile System Design
6.3.2 The purpose of an interface isn’t instantly clear
When you stumble upon an interface that somebody else introduced, it’s often not instantly clear why
there is an interface.
You may wonder if it exists for testing, or for polymorphism (such as supporting a heterogeneous array).
Or maybe because a coworker read a blog post about SOLID principles, stating that all dependencies
need to be hidden behind an interface.
One consequence is that you’ll stumble upon code, such as let tutorAPI: TutorAPIProtocol,
but you can’t instantly see the actual type until runtime.
For example, if you follow TutorAPIProtocol, your editor won’t navigate to a concrete type. It will
navigate to the interface definition, which can be anything at runtime. As a result, you must search
manually to figure out which types implement the interface.
Some might say; “But we know what’s behind the interface. It’s TutorAPI, it’s always going to be
TutorAPI in production code”. However, can you guarantee that others (and your future-self) will
memorize what’s behind the interface? Is it clear that someone introduced the interface for testing, or
maybe other use-cases?
Others might say that it shouldn’t matter what is behind the interface. Because it hides underlying
types and promotes decoupling. That’s a great idea until you have to debug their code. Debugging
multiple layers of code can be difficult; For maintenance, it’s easier to investigate a few classes, as
opposed to jumping between interfaces.
The point is, we can’t always avoid introducing an interface, we have to make our code testable
after all. But, we should avoid introducing interfaces with little thought, not just because they are a
dependency.
In this book, we try to keep the number of interfaces low and we try to find a balance between fewer
interfaces while keeping our code decoupled – and, later on, modular.
Since we’ll work with more concrete types, our team-members don’t have to wonder as often why
someone introduced an interface. They can navigate straight to the concrete type when inspecting the
codebase.
6.4 Compiler-flags and environments
A reason why we may need DI is that it gives us the ability to swap environments, such as connecting
to a staging or acceptance server, instead of production.
128
Mobile System Design
We could choose to avoid DI altogether and let API decide what network to run on, based on the
environment.
For instance, API might use a config that points to specific paths. Using compilers flags, such as
#if DEBUG, we can connect to a different server for internal builds, as a different one for production
builds.
1 // Inside the API domain
2
3 enum NetworkConfig {
4
5
#if DEBUG
6
static let serverURL = "https://staging.myawesomestartup.com/dev"
7
#else
8
static let serverURL = "https://www.myawesomestartup.com/"
9
#endif
10
11 }
But the problem with this approach is that we’ll be sprinkling these flags around in our codebase. For
instance, what if we want to test API ? We can’t point to a testing server. With this approach, API will
need another flag for testing to get different behavior, such as not connecting to any network.
That’s only for API. Over time, we might end up with dozens of hundreds of these flags. This makes it
harder to reason about a codebase, since many types behave differently depending on the environment.
A better goal is to centralize these compiler-flags, which is the approach we’ll take in the following
chapter.
Because once we consolidate these compiler-flags, we have one location to look at for specific environments and behaviors. Everything else remains the same, or more similar.
By doing so, we can keep API the same regardless of where it connects to. Making it easier to reason
about API.
6.5 Singletons, or "What if there’s only one instance of something?"
A third reason you might need dependency injection is when you want to avoid re-creating class
instances.
It wouldn’t make sense to have each class initialize its own API class. They all connect to the same
server and handle the same.
It adds unnecessary complexity with no benefits.
129
Mobile System Design
Let’s say a user logs out. With multiple API instances, you would have to trigger logout calls to all API
instances, instead of just one.
The same goes for a User class. Having multiple user instances spread through an app is asking for
problems once a user logs out or switches.
Updating a user in one location, yet reading from a different user’s data in another will be a fun time to
spend your days debugging.
However, when you have a single instance, you might wonder, “Why not use a singleton if you have a
single instance”? After all, it would make DI a lot simpler – as we’ll see shortly.
But, it has a lot of downsides. We’ll cover some actual bugs we can get by using singletons, then we’ll
go over when it’s okay to use them.
The goal is that you’ll be able to explain eloquently when a singleton does, or does not, make sense.
Giving you the tools necessary to show alternatives where you can solve the same problem without
singletons.
6.5.1 Setting up a singleton
As an example, let’s see what this would look like if we implemented singletons. But, after this chapter,
we’ll take a different approach.
Setting up our code with a singleton is quite convenient. We’ll start by making API available to everyone,
saving us the hassle of having to inject it.
We’ll re-use the Network protocol from the system-wide testing chapter.
1 final class API {
2
// We define a static property to be used by other types.
3
static let shared = API()
4
5
// We make the network swappable
6
static var network: Network = ProductionNetwork()
7
// ... snip
8 }
The three classes that use API, will directly depend on it via its shared property.
1 final class TutorAPI {
2
let api = API.shared
3
// ... snip
4 }
5
6 final class TODOList {
7
let api = API.shared
130
Mobile System Design
8
// ... snip
9 }
10
11 final class Calendar {
12
let api = API.shared
13
// ... snip
14 }
As a result, we don’t need to inject anything at CourseAPI.
1 final class CourseAPI {
2
3
// CourseAPI doesn't know about API anymore like before.
4
let tutorAPI = TutorAPI()
5
let todoList = TODOList()
6
let calendar = Calendar()
7
8
init() {
9
// We aren't passing anything to the inititializer.
10
}
11
12
// ... snip
13 }
At the location where we set up our environment, all we need to do is define the network to connect to;
Depending on our build, we’ll either connect to a staging or production environment.
Somewhere in our app, we can make a small method, called setupEnvironment(), to set up the
network depending on the build settings.
NOTE: We’ll use compiler-flags, as indicated by #ifdef DEBUG#, to differentiate between a staging of
production environment.
1 func setupEnvironment() {
2
// Depending on the environment, we set up the environment on API.
3
#ifdef DEBUG
4
API.network = StagingNetwork()
5
#else
6
API.network = ProductionNetwork()
7
#endif
8 }
Implementing this singleton has been very convenient and a big reason why people (ab)use them.
But even though they are convenient, it poses problems. Let’s find out why.
131
Mobile System Design
6.5.2 Singletons are often abused as shortcuts
In daily work, you might hear people say that singletons hide dependencies, because you can’t see
what’s passed around. That is true on a class-scope, where you can’t instantly see what is being passed
to a class.
But in a large app, it’s not instantly clear either, even without singletons. People put dependencies in
containers or group them into a new type.
Developers exacerbate this problem once a codebase has tons of interfaces or protocols, because it’s
not instantly clear what is hiding behind an interface.
In some cases, I’d argue that a singleton can make code easier to understand, since there is no complicated DI to decipher.
Looking at the example from the previous section, some might say that the singleton-solution is more
readable than all the other DI solutions that we’ll cover in this book.
Singletons are extremely convenient to use and easy to introduce. But singletons can become a giant
pain later in time, once a program matures. It’s like writing code that has technical debt from day
one.
Some think it’s okay to create a singleton where you only have a single instance. But the fallacy is that
people assume there will be a single instance forever.
It’s one thing to state “We only need one instance”, but it’s a bold claim to state “We only need one
instance today and that will never change in the future.”
With a little effort, we’ll be able to set up our DI solution while avoiding the risks that singletons bring.
6.5.3 Solving problems for the future
Another nuance is that mostly in programming we should not solve problems “for the future”.
For instance, let’s assume that we suspect students will also become tutors.
It would be a waste of time to come up with a fancy way to merge students with tutor models, or to come
up with a generic Teaching interface. We might never need it, so implementing something upfront
“in case we need it later” is a poor investment of our time and a sure path to over-engineering.
However, this train of thought does not always apply to singletons. With singletons we do need to think
upfront about the future. Because if we ever do need to make a singleton a regular injectable type
again – because we need multiple instances for example – then it becomes much harder to introduce
dependency injection retroactively in a mature codebase.
132
Mobile System Design
This is because DI is a load-bearing wall of a codebase and changing it affects the majority of our
applications.
Imagine a User singleton that’s used twenty classes deep in a large application. Three years later, your
team decides the app needs to support multiple users. Now you need to get rid of the User singleton
everywhere. It will be a giant time-investment having to come-up with a DI solution retroactively.
Handling global state that singletons bring means either two things: Engineers will spend a lot of time
to remove the singleton to retroactively add DI, and educate and document the new DI way.
Or, developers will double-down and add synchronization-points to a singletons well as the classes
that implements them. Thus making it even harder to understand and maintain the codebase.
But, if developers were passing a User from the beginning – which doesn’t take that much effort –
then these problems become almost non-existent.
6.5.4 Singletons hinder modularization
With mobile development, we might decide to break up our codebase into their respective libraries or
modules.
If you, later on, decide to extract bits and piece of our codebase into their own module, then singletons
won’t easily let you.
For instance, let’s assume that we sprinkle around a User singleton across the entire codebase. Perhaps
to read a user’s name, or to display its email, or to check whether a user is logged in.
Next, let’s say we want to extract API to one module, and CourseAPI, TODOList, Calendar, and
TutorAPI to another own module.
Looking at the graph, the User singleton lives inside the app. So we can’t extract the modules without
breaking this strongly tied dependency.
This structure will not work. The code will not compile.
Note: We’ll omit most types for demonstration. Normally we’d have data models and a giant app graph,
too.
133
Mobile System Design
Notice how this graph is bidirectional because of tight-coupling. The app passes values to and instantiates the modules. But, the modules still rely on the User singleton in the app, which they can not
access.
Inside the modules, you would need to find all locations of the User singleton and sever the connection,
and that’s just for one singleton.
We have to remove all related singletons if a singleton depends on others, making the problem worse.
In our tiny example, there are at least three connections to cut. But this number can grow substantially
in a larger application.
6.5.5 Passing values across modules instead
A quick and dirty solution would be to move the singletons to a module so that the app and all other
modules can access the singletons. But you’ll end up with a “junk drawer” module full of singletons.
Another solution is to move the User singleton to the lower module – API in this example. But now,
all code and modules that rely on User will depend on the API module. Some might try to avoid this
by introducing an interface module to sever this connection.
Either way, none of these “solutions” are ideal. We’re just painting ourselves into a corner. These
solutions would only exist reactively, as an after-thought to deal with singletons, as opposed to a strong,
planned design.
134
Mobile System Design
A better solution would be to support dependency injection in the features from the start.
In that scenario, from the app you can pass the User to certain classes – such as API and CourseAPI
– and from there on out, these classes can take care of passing the User around internally.
The dependency graph becomes unidirectional. Because now there is a single entry-point per feature
to pass the User dependency.
Notice how in the graph, the app solely needs to pass the user to each module, but there is no connection
going back reaching to a User singleton.
This approach will clean up the dependencies and make it easier to move code around, such as
extracting code to their own modules.
Passing dependencies is the approach we’ll take in this book. In an upcoming chapter we’ll take a
deeper look at passing dependencies across modules.
135
Mobile System Design
6.5.6 Singletons, thread-safety, and global state
Without proper synchronization mechanisms, multiple threads can access and mutate the instance
simultaneously. This leads to race conditions where data will be in an incorrect state.
To give an example, below we have a PaymentProvider that allows us to transfer money from one
account to another using the transferMoney method.
It assumes there is only one user, hence we use a singleton User.
1 final class PaymentProvider {
2
3
let transferAPI: TransferAPI
4
/// Transfer money to an acccount
5
/// - Parameters:
6
///
- amount: Amount in cents
7
///
- targetAccount: The target account to transfer to
8
func transferMoney(amount: Int, to targetAccount: Account) async
throws {
9
// Initialize the payment and wait until it's done.
10
let updatedBalance = try await transferApi.transfer(amount:
amount, from: User.shared.account, to: targetAccount)
11
12
// After the transfer completes, update the user's balance.
13
User.shared.account.balance = updatedBalance // Danger!
14
}
15
16
// ... snip
17 }
The body of transferMoney only has two lines, and already there is a critical bug; Once we call the
transfer on transferAapi, we fire off an asynchronous method. This means that during that time,
theoretically anything could happen to the user singleton. The user might become logged out or the
app might swap to a different user model.
In most cases nothing will happen, but if we fire off the transfer and quickly log out and log in as a
different user, the wrong user’s balance is updated!
It will probably never happen in practice because logging out will take more time, or perhaps the UI
blocks logging out during this operation. But the key word here is "probably".
On a large codebase these issues can sneak in later, and it might be hard to find this bug because this
will rarely – if ever – happen during your own tests.
Maybe it’s fine now, but in the future the transferAPI might be modified to queue up payments,
meaning that it performs its operation slightly later in time during batches, increasing the risk of this
bug.
136
Mobile System Design
Or perhaps a slow network or a retry mechanism occurs, then even with a single user it can break when
logging out while this operation is still ongoing. Not to mention, with a multi-user app the chances of
bugs increases even more.
These are all issues that are initially not a problem, but over time – thanks to a large (convoluted)
codebase – the singleton can become a silent killer.
6.5.7 A thread-safe singleton is not enough
Developers often associate singletons with thread-unsafe code.
But, by using synchronization mechanisms, you can make singletons thread-safe.
Unfortunately, it’s often not enough.
The problem is thread-safety in the context surrounding a singleton, that sometimes gets overlooked.
For instance, the User singleton might have atomic operations or locking mechanisms to protect its
data.
In that case, it will help keep the singleton’s data in a correct state. Let’s say two different classes are
simultaneously updating a user’s first name and last name. One class updates the User singleton to
‘Larry Page’, and another classes updates it to ‘Steve Jobs’.
By making User thread-safe, we ensure that the user doesn’t end up with a name such as Larry Jobs,
or Steve Page.
However outside of the User singleton, in the payment scenario, the threading bug remains.
We still have the issue where the user can swap underneath us during a payment transaction. Because
a singleton has global state.
By making User thread-safe, it remains its data-integrity, but it’s not thread-safe in the context of a
payment.
6.5.8 Removing a singleton dependency
The payment example with a singleton User has a few fixes.
You can verify the user id again before updating the balance, to ensure you’re completing a transaction
to the same user that started it. This has to be done in a thread-safe manner – such as using locking
mechanisms, however that is outside the scope of this book.
Another solution is to merely pass a User. This way we avoid having to wrap our heads around multithreading and synchronization points.
137
Mobile System Design
In that case, we would pass the user to transferMoney method. We’ll update the code to make sure
we only refer to the user we passed in, not the singleton user.
1 final class PaymentProvider {
2
3
let transferAPI: TransferAPI
4
/// Transfer money to an acccount
5
/// - Parameters:
6
///
- amount: Amount in cents
7
///
- user: The user to transfer money from
8
///
- targetAccount: The target account to transfer to
9
func transferMoney(amount: Int, from user: User, to targetAccount:
Account) async throws {
10
// We now transfer from the passed user's account
11
let updatedBalance = try await transferApi.transfer(amount:
amount, from: user.account, to: targetAccount)
12
// We update that same user's balance
13
user.account.balance = updatedBalance
14
}
15
16
// ... snip
17 }
NOTE: Passing dependencies as singletons is a technique for gradually phasing out a singleton.
Whatever happens to the singleton user, this method is only updating the user on the local scope in the
function; Even if the current user changes to a new user, the transfer code still transfers to the passed
user.
Since we now pass a User to PaymentProvider, we have to ask ourselves where we get it from. This
means an ancestor – a type that owns or creates PaymentProvider – can grab the User singleton,
and pass it to PaymentProvider.
We get rid of the singleton in the PaymentProvider. Now we end up having a mixed solution where
the codebase contains a User singleton, but PaymentProvider doesn’t use it.
This can be a decent decision and ironically we are applying DI again, except now we start the DI deeper
in our class hierarchy, not at the root where we set up a project.
Not to mention, the singleton is still there for anyone to grab, but at least it’s a start to get rid of
singletons.
To completely remove a singleton, you can keep removing singletons at “leaf” instances that don’t
pass the singleton around, such as PaymentProvider above.
By continuing to find a singleton and replacing it with injection, you’ll end up with a codebase where
the singleton is only instantiated at the app’s main entry point. This can be a great technique for when
you can’t avoid singletons, such as third-party frameworks forcing us to use a singleton.
138
Mobile System Design
6.5.9 Use-cases for singletons
Despite other patterns often being a better alternative, it doesn’t mean we should always avoid singletons.
Singletons can be a good idea when you need to ensure that only one instance of a class is created.
One example is caching to disk. Let’s imagine we cache locally stored data to the disk, but we load it
into memory to maintain performance. In that case, enforcing a single instance (via a singleton) can be
useful; It helps us prevent the need to fetch the data repeatedly, which can be time-consuming and
resource-intensive.
In other cases, we can’t avoid singleton behavior at all.
For instance, we might have a couple of classes that rely on file storage. We will always write to a
single mutable disk (on phones at least), no matter how we split our classes or how many instances we
support.
When we can’t avoid singleton behavior, it can be better to embrace this constraint and make it explicit
that we’re dealing with a single instance.
Another example is logging. We can make dozens of logging instances, but all these would still write to
a similar location. Either to disk or print to the terminal. Having multiple instances doesn’t offer us
much value, in which case an explicit singleton can often be the better choice.
6.5.10 Passing values pays off
Overall, while singletons can be useful in certain situations, they should be used with caution and only
when they provide obvious benefits over other design patterns. You should consider the potential
downsides of singletons before using them in your code.
As you’ll see in this book, we can make our solutions work with passing values, even for deeply nested
dependencies. You’ll be able to keep the benefits, and best of all: You’ll avoid a bunch of multi-threading
bugs waiting to bite you.
The price to pay is that we’ll trade in some convenience and put in a bit more effort upfront, which will
save us a lot of headaches in the long term.
139
Mobile System Design
6.6 What we covered
In this chapter, we covered:
• Dependency injection for apps is most commonly needed for three reasons: Testing, I/O (such as
connecting to various servers), and making sure we pass around a single instance.
• Passing dependencies around in a boring way is often the quickest way for coworkers to understand.
• Third-party solutions have a cost; Such as “magic” code, or depending on a framework that’s
slow to update.
• Third-party solutions can also depend on other third-party solutions. If any of these are slow to
update, then that negatively affects your codebase.
• Introducing a large amount of interfaces solely for testing or dependencies can make a codebase
more porous and harder to understand.
Compiler flags
• Try to consolidate compiler-flags to make it easier to reason about a codebase.
• The entry-point of an app, such as Main, is a suitable location to set up dependencies and
configurations.
Singletons
• Singletons can be considered the easiest way to pass dependencies around, because you don’t
really have to think about dependency injection that much.
• Developers often abuse singletons as shortcuts. They are easy to introduce and use, and defer
having to think about dependency injection.
• Even if you make a singleton thread-safe, it’s not enough. The code that uses the singleton still
needs synchronization mechanisms to fully support thread-safety.
• Singletons hinder modularization. If you want to extract code into its own module, you first
have to take out all the singletons dependencies. This often means you have to come up with a
different dependency injection solution, after all.
• When you have one instance of something, don’t default to using a singleton.
• If you have a single instance, you can’t guarantee that it will always be a single instance. In that
case, it can get time-consuming to replace a singleton with support for multiple instances.
• Singletons are great when you want to enforce a single instance. Such as a shared database
instance, or writing to disk. This ensures that code gets funneled to a singleton where protection
mechanisms are in place.
• Removing singletons is easiest when starting with a leaf class in the code-hierarchy, and pass, or
inject the singleton as a value. Then work your way up the dependency-tree.
140
7 Sane dependency injection without fancy
frameworks
In this chapter
• Dependency injection techniques using vanilla code
• How to set up dependencies trees
• How to handle transitive dependencies while keeping code decoupled
• How to keep code decoupled while still limiting the number of interfaces
• How to handle deeply nested dependencies
• How to avoid pre-creating all dependencies by utilizing lazy loading techniques and factories
• How to handle dependencies that are injected from multiple locations
• How to set up dependencies we can’t create upfront
In the previous chapter, we’ve covered the use-cases for dependency injection, and why we need it.
In this chapter, we’ll start applying dependency injection to our own codebase, specifically the Course
feature.
We’ll apply a straight-forward vanilla technique where we pass values around. By doing so, we’ll hit
speed-bumps that we’ll solve one by one.
One of the most common issues is the ‘ABC problem’. A situation where class A owns class B, which
owns class C. If we’re not careful, we will pass C to A, which poses a problem since we don’t want A to
be aware of C.
It’s a natural problem we run into in day-to-day situations.
Teams may want to reach for a formal solution for this scenario. But, in reality, we can solve this by
flipping our structure inside out, which we’ll cover extensively in this chapter.
Another problem we’ll face is passing dependencies around through a larger number of types, which is
this chapter will address.
Sometimes, we have the problem where we can’t always create an instance of a type, because we
might not have all its dependencies available. We’ll use lazy dependency techniques, such as factories,
to handle those scenarios.
141
Mobile System Design
You’ll see that with a few techniques, you can avoid needing another third-party solution to help you
“pass things around” in a sane way.
The bonus of the techniques in this chapter is that you can use these techniques in a wild variety of
platforms and programming languages.
After you’re done with this chapter, we’ll continue with this approach to handle dependencies on a
larger scope.
7.1 A naïve solution
Before we move on with a better approach, let’s start with a naïve solution to get a deeper understanding
of the problems we’ll face.
In the previous chapters, we covered how API shouldn’t dictate which network to use. Nor should it
know that it’s used for tests. But that has implications for how we set up our structure.
Because now a different location outside of the API domain will have to decide where API connects
to.
A naïve solution would look as follows, where we pass API to CourseAPI. This allows CourseAPI to
accept any API instance that connects to any server. CourseAPI can then use API to create instances
of its own dependencies, namely TODOList, TutorAPI, and Calendar.
1 // Naïve solution!
2 final class CourseAPI {
3
4
private let tutorAPI: TutorAPI
5
private let todoList: TODOList
6
private let calendar: Calendar
7
8
// API and its network is passed in.
9
init(api: API) {
10
// Now we instantiate all the properties using api
11
self.tutorAPI = TutorAPI(api: api)
12
self.todoList = TODOList(api: api)
13
self.calendar = Calendar(api: api)
14
}
15
16
// ... snip
17 }
A benefit of this approach is that it’s straightforward. We pass API, since that allows CourseAPI to
connect to either a production or staging or testing environment. It allows CourseAPI to set up its
own dependencies.
142
Mobile System Design
The catch is that CourseAPI now knows about API, but it doesn’t directly need API to function. It’s
only using API to set up its transitive dependencies.
Let’s think about it: Is setting up dependencies really the responsibility of CourseAPI?
Let’s go over the ABC problem to understand the issues we are introducing.
7.2 Deeply nested dependencies; The ABC problem
The problem we just hit is something we can call the ’ABC problem’.
It’s a scenario where class A depends on class B, and class B depends on class C, but we don’t want
class A to know about class C.
To give a concrete example, let’s replace ABC with CourseAPI, TODOList, and API.
We don’t want types to know about its transitive dependencies – also known as its dependencies’
dependencies.
If we don’t give DI a deeper thought, we would introduce this problem by merely passing API to
CourseAPI, which was the situation in the previous section.
With our current solution, CourseAPI becomes a more tightly coupled to the types it depends on.
Because now CourseAPI is aware of API, instead of only knowing about TutorAPI, TODOList, and
Calendar.
For example, let’s say that TutorAPI now needs API and User in its initializer. To make this work with
the naïve solution, you’d need to pass User to CourseAPI, and you need to update the initializer’s
body in CourseAPI so that it passes User to TutorAPI, and that’s just one example.
CourseAPI now needs to update together with its dependencies. It’s too aware of its properties’
properties, so to speak.
143
Mobile System Design
It’s not the end of the world if this happens. In fact, it’s normal that this happens here and there in a
codebase, but we can considere this a code smell if it happens everywhere by default.
If we were to keep taking this approach all the time. Then, over time all dependencies would be passed
around everywhere to where they’re needed. That would cause us to weave all types across the entire
codebase. We would have to update tons of classes just to pass a new dependency around or update
an initializer, or to delete a dependency.
It would make us more rigid, and it slows down our development speed.
The answer to this is to ensure that types are only aware of their direct dependencies. Let’s continue to
see how.
NOTE: Alternatively, there are platform specific options to deal with transitive dependencies. For instance,
in SwiftUI we can use EnvironmentObject to pass dependencies to a subview that’s deeply nested.
But that approach has its own considerations (e.g. it can crash on misuse), not only that, it’s usable only
on Apple platforms and restricted to SwiftUI.
7.2.1 Flipping the hierarchy inside out
Luckily, we can avoid the ABC problem by flipping our hierarchy inside out. Let’s see how that works.
Since we want to keep API unaware of the network, we need to pass something to it.
NOTE: If you remember, in the System-wide testing chapter we passed MockedNetwork to API.
We start by creating a network instance, such as ProductionNetwork, StagingNetwork, or
MockedNetwork. They connect to the production, staging, or a mocked (fake) environment.
These types adhere to the Network interface that API uses, making API unaware of which network it
is using.
The network gets passed to API, after which we create the types below that, and so on until we end up
with a CourseAPI instance again.
Let’s flip the graph, so it closer resembles the steps we take in code. Types get passed from top to
bottom in order to create the dependency hierarchy.
144
Mobile System Design
As a result, we are inverting the order in which we create the dependencies.
Instead of creating CourseAPI first, we will now create CourseAPI last.
We flip the hierarchy upside down. Hence why you sometimes see the term Inversion of Control. (But
we agreed not to sound pretentious).
This way, we now have a complete dependency hierarchy that can connect to multiple servers and it
avoids the ABC problem. Let’s set that up now in code to see how that would work.
7.2.2 Setting up the environment
Looking at the code, we’ll introduce a new method called setupCourse() to set up our app for
production environments. Since it’s part of bootstrapping the app, this method can live at the entrypoint of our app, such as Main or AppDelegate (iOS-specific).
Inside that method, we’ll set up all dependencies to create an CourseAPI instance. Because we’re
dealing with production code (as opposed to testing code), we’ll pick ProductionNetwork to set up
API. This is a new type that we just introduced that will make the actual network call to a production
environment.
Alternatively, we can also choose to set up StagingNetwork here for internal builds to connect to
internal servers.
145
Mobile System Design
After setting up API, we can pass it to TutorAPI, TODOList, and Calendar, and finally we pass those
to CourseAPI.
As a result, we end up with a CourseAPI instance that connects to the production environment, and
we avoid the ABC problem.
1 class Main {
2
// Calling this method returns a class hierarchy that runs on the
production servers.
3
func setupCourse() -> CourseAPI {
4
// In the real app we make a production network to be consumed
by API.
5
let network = ProductionNetwork()
6
7
// We pass or "inject" the network to API.
8
let api = API(network: network)
9
10
// Then we can instantiate all other types.
11
let tutorAPI = TutorAPI(api: api)
12
let todoList = TODOList(api: api)
13
let calendar = Calendar(api: api)
14
15
// And we can pass the types to CourseAPI.
16
let courseAPI = CourseAPI(tutorAPI: tutorAPI, todoList:
todoList, calendar: calendar)
17
return courseAPI
18
}
19
20
// .. The main class would set up more, but we're leaving that out
for example purposes.
21 }
NOTE: In this example, we create CourseAPI during the setup phase. In a mature app, we would set up
the entire main application, such as login screens, main navigation, and so on.
7.2.3 Updating CourseAPI
We’re almost done, we still need to update CourseAPI to allow for this change.
We just covered how CourseAPI should not receive API since it doesn’t directly use it. Instead,
CourseAPI needs to receive its direct dependencies via injection, or as we can call it “just passing
values”.
Unlike passing API, which is a transitive dependency, we’ll pass direct dependencies to CourseAPI,
since it’s directly use them.
We’ll update CourseAPI so that it receives all its direct dependencies. Now CourseAPI will not
instantiate them anymore using the transitive API dependency.
146
Mobile System Design
1 final class CourseAPI {
2
3
// CourseAPI now receives dependencies.
4
// It doesn't instantiate them.
5
private let tutorAPI: TutorAPI
6
private let todoList: TODOList
7
private let calendar: Calendar
8
9
// We assign the received dependencies to local properties
10
init(tutorAPI: TutorAPI, todoList: TODOList, calendar: Calendar) {
11
self.tutorAPI = tutorAPI
12
self.todoList = todoList
13
self.calendar = calendar
14
}
15
16
// ... snip
17 }
Now, CourseAPI receives all its dependencies, and it’s not aware of API. We solved the ABC problem.
7.3 Compiler-flags on the outer edge of your application
We’ve covered how sprinkling compiler-flags around makes it harder to reason about a codebase.
To solve this, it’s better to centralize them if possible.
A usable location for compiler-flags is the starting point of your application where we can set up and
bootstrap everything. Because an entry-point of an app is a common location where people set up
environments. This makes it easier for coworkers to find app configurations.
We already created an setupCourse() method in our Main class, which is where we bootstrap our
feature. Since that’s all we have at the moment, let’s use this location to add compiler-flags.
On a debug build, we may want the app to connect to a staging (internal) server instead of production.
We can use compiler-flags to swap between a StagingNetwork or ProductionNetwork.
We’re not sprinkling around compiler-flags throughout various types. Now all the other code remains
the same, thus making it easier to reason about our codebase.
1 func setupCourse() -> CourseAPI {
2
// Depending on the environment, we either create a build that
connects to staging or production.
3
#ifdef DEBUG
4
let network = StagingNetwork()
5
#else
6
let network = ProductionNetwork()
7
#endif
147
Mobile System Design
8
9
10
11
12
13
14
15
16
17
18 }
// The rest is exactly the same, regardless of the network.
let api = API(network: network)
let tutorAPI = TutorAPI(api: api)
let todoList = TODOList(api: api)
let calendar = Calendar(api: api)
let courseAPI = CourseAPI(tutorAPI: tutorAPI, todoList: todoList,
calendar: calendar)
return courseAPI
We can’t avoid all compiler-flags this way. We use compiler-flags in different places to turn on or off
features that require specific OS versions or platforms.
Where possible, however, centralizing compiler-flags makes it easier to see what’s going on.
Apart from the networking layer, we now end up with a class hierarchy where our types all work exactly
the same regardless of which environment they run in.
We can pat ourselves on the back, because with a brief set up, we created a DI hierarchy for a feature.
Before we battle a more complicated setup, let’s understand the secret sauce behind how this solution
avoids the ABC problem.
7.4 The secret sauce
The secret sauce behind avoiding the ABC problem is that the creator of a type, in our case Main, has
access to all dependencies.
At the start, our application has no hierarchy yet. Main has access to all dependencies and their
transitive dependencies.
Main was working with a flat dependency hierarchy.
When setting up the dependencies, our hierarchy looks more as follows, where Main can reach for all
types.
After we’re done, all classes have their own dependencies, forming the hierarchy:
148
Mobile System Design
A second ingredient for this secret sauce is that we connect all types bottom-up. We start with the
lowest-level elements, namely a network that we pass to API. We then initialize TODOList, Calendar,
and TutorAPI, and finally CourseAPI..
7.4.1 Breaking the ABC rule
One important thing to note is that the class that sets up the dependencies breaks the ABC rule.
For instance, Main does not directly use API, yet it creates it and sets it up. Which is similar to how
CourseAPI originally used API to set up its properties.
Throughout an app, various locations will break the ABC rule. However, the goal is to minimize this
from happening too much.
Try to decide specific locations where you will set up your dependency hierarchies. We’ll explore this
more in the upcoming chapter.
149
Mobile System Design
7.5 Growing the app
To see how to handle dependencies on a slightly larger scale, we’ll introduce a new Marketplace
feature. This is the screen that users see before they choose a tutor.
Within a marketplace, the user can search for and browse courses, compare prices and ratings, subscribe, and pay for a tutor’s course.
To give it more context, the overview screen looks as follows – although we won’t focus on UI in this
chapter.
150
Mobile System Design
7.5.1 Extending the graph with more classes
To support this functionality, let’s introduce a new Marketplace class. This class can handle the data
aspect, such as fetching and filtering courses, and paying for a course.
In our class hierarchy, that means that CourseAPI will now be a dependency, used by Marketplace,
thus making Marketplace the top element.
Remember how we removed Store from CourseAPI in the previous chapter? Let’s reintroduce it
again to grow our hierarchy. But, let’s move it to API so that we can store any data from the network
responses, not just only courses.
This approach could even give us the ability to deliver an offline mode for our app.
Store itself depends on Storage, which is a protocol (interface) which allow us to swap between a
memory store or file store. One reason to have a memory store is for testing.
We’ll offer two implementations, such as MemoryStorage (with the implementation we made in the
Holistic chapter) and FileStorage (a new implementation).
151
Mobile System Design
7.5.2 Flipping the graph
By adding these new types, we’ll have to deal with the ABC problem again, but now on a larger scale.
We don’t want Marketplace to know about StorageType nor Store nor API.
Neither do we want CourseAPI to know about API as before, neither do we want CourseAPI to know
about Cache and Storage.
This is to avoid dependencies knowing about their transitive dependencies.
To set this up, we’ll apply the same method as before.
During the set up phase, we will instantiate all the types and connect it all together, starting from the
deepest element.
Let’s flip the graph upside-down again, and work our way down, so it closer resembles the approach
we use in code.
Note that the graph is exactly the same as before, only represented upside-down.
Notice how StorageType’s dependencies are now up top, which is what we’ll initialize first.
152
Mobile System Design
7.5.3 A larger ABC problem in code
In code, we can express this similarly.
Let’s choose MemoryStorage for the store since we already used it in the Holistic-Driven Design
chapter.
Then we can initialize Store, then API, and so on. The result is that we end up with a CourseAPI
instance again, which we’ll use to initialize and return a Marketplace instance.
NOTE: The setupCourse() method is now renamed to setupMarketplace(), since it now returns a
Marketplace instance.
1 class Main {
2
// setupCourse is renamed to setupMarketplace.
3
// It now returns a Marketplace instance.
4
func setupMarketplace() -> Marketplace {
5
// We initialize the deepest element, MemoryStorage.
6
let storage = MemoryStorage()
7
8
// Then we can initialize the Store and continue as before.
9
let store = Store<Data>(storageType: storage)
10
11
// We initialize Network and API again
12
#ifdef DEBUG
13
let network = StagingNetwork()
14
#else
15
let network = ProductionNetwork()
16
#endif
17
18
// API depends on store now, too
19
let api = API(network: network, store: store)
20
21
// Again, we can instantiate CourseAPIs dependencies as before.
22
let tutorAPI = TutorAPI(api: api)
23
let todoList = TODOList(api: api)
24
let calendar = Calendar(api: api)
25
26
// And we can pass the types to CourseAPI.
27
let courseAPI = CourseAPI(tutorAPI: tutorAPI, todoList:
todoList, calendar: calendar)
28
29
// Now we can create Marketplace by passing CourseAPI.
30
let marketplace = Marketplace(courseAPI: courseAPI)
31
return marketplace
32
}
33
34
// ... snip
35 }
153
Mobile System Design
With a few extra lines, we managed to set up the entire hierarchy, and yet zero dependencies bleed
through where they don’t belong. We solved the ABC problem once again.
With this setup, no matter how many dependencies someone throws at us, we can keep solving it using
these principles.
Notice that we kept the number of interfaces low. We only have Network and StorageType. Fewer
interfaces stimulate testing code that we ship as opposed to mocked code. As we covered in the
system-wide testing chapter.
One downside of this approach is that we instantiate everything upfront, or eagerly. That is usually not
a problem, but creating the entire dependency-tree upfront is not always what we want, and often, it’s
not even possible! Because not all dependencies might be available when the app just started.
Before we solve this problem using factories, let’s first deepen our understanding of lazy dependencies.
7.6 When dependencies aren’t available
Depending on the location in the code, we can, or can not, create a dependency.
We initialized Marketplace by creating all of its dependencies. We also created its dependencies’
dependencies – also known as transitive dependencies.
We managed to create all dependencies in setupMarketplace(), because we are able to initialize
everything, all (transitive) dependencies are available to us.
However, oftentimes there will be cases where we can’t create an instance of a type, because we might
not have all of its (transitive) dependencies available.
For a better understanding, let’s cover how that works and why we need it for our project.
7.6.1 A payment flow
Imagine that we’re building a payment flow; A user navigates to the marketplace, picks a tutor, and
pays for a tutor’s course.
Let’s assume that Marketplace is the one creating a Payments instance to support this feature.
We’ll update the graph to show how Payments is a new dependency for Marketplace. Notice
how Payments comes with its own dependencies, including a PaymentProvider model. The
PaymentProvider represents the way a user wants to pay, such as via credit card or Paypal.
154
Mobile System Design
NOTE: If this were a real iOS app, Apple would enforce In-App Purchases here as the sole payment-provider.
But, let’s continue as if we don’t have them looming over us right now.
But now we hit a problem when setting up the Marketplace dependency.
Ideally, during setupMarketplace(), we can create a Payments instance, and use that to create a
Marketplace instance like before.
But notice how Payments relies on API and PaymentProvider.
However, during setupMarketplace() the user hasn’t selected a PaymentProvider yet. So it’s not
available. Because of this, when the app initializes at Main, we can’t create a Payments instance to
give to Marketplace during setupMarketplace().
Alternatively, we could try to let Marketplace create its own Payments instance. Marketplace can
pass a PaymentProvider, but unfortunately it has no direct access to API.
155
Mobile System Design
Our dependencies seem to have split. Both Main and Marketplace can only supply one dependency
each for Payments.
A quick solution would be to give API to Marketplace, then it can initialize Payments later. This
solves our problem, but, it introduces the ABC problem. Because this way, Marketplace will be aware
of API, which is a transitive dependency.
As we established, having types be aware of transitive dependencies comes with its own set of issues.
Let’s cover one more scenario before we come up with a solution that solves all our issues.
7.6.2 Optional dependencies
Let’s imagine that users are restricted to pay with merely a single payment provider. That means that
Payments can create its own PaymentProvider, so it doesn’t need to be passed anymore.
In that scenario, Main can create a Payments to give to Marketplace, because it doesn’t need to
supply a PaymentProvider.
But, we don’t need to present this flow unless a user purchases a tutor’s course. Pre-creating Payments
during setupMarketplace() would be a waste of a device’s resources, and might cause the app to
start slower.
We can consider this dependency optional. During an app’s lifetime, an app may or may not need the
dependency. It depends on the action of a user, after the dependencies are set up.
To solve this, we can let Marketplace create a Payments only when needed.
However, as we established, we need API to create a Payments instance, and giving API to
Marketplace would introduce the ABC problem.
For both scenarios, lazy initialization will be the solution to this problem. Let’s cover how that works in
practice.
156
Mobile System Design
7.7 Lazy dependencies
From the two scenarios we just discussed, we’ll handle the first scenario since it’s more complex. It’s
the scenario where the payment flow does require a PaymentProvider.
We decided we need a Payments instance later or not at all, and we’ll use lazy instantiation for that.
Unlike a regular dependency, a lazy dependency is a function that returns a dependency. So, instead
of passing Payments, we can pass a function that returns a Payments instance.
However, juggling (anonymous) functions might be unwieldy to work with. To make our code easier to
comprehend, we can formalize this function by putting it in a PaymentFactory.
This factory is a dependency of Marketplace because that’s the type that will create a Payments
instance if needed at a later time.
NOTE: Unlike dependencies, actions are depicted as arrows with a dashed outline.
7.7.1 Expressing a factory in code
Looking inside PaymentsFactory, we can see it has a makePayments(paymentProvider:)
method. It takes a PaymentProvider and returns a Payments instance.
157
Mobile System Design
Marketplace will be the one calling makePayments(paymentProvider:) and pass it a
PaymentProvider. But, notice that the Payments it creates also requires an API instance.
1 // This will not work yet.
2 final class PaymentsFactory {
3
4
// makePayments requires a PaymentProvider to return a Payments
instance.
5
func makePayments(paymentProvider: PaymentProvider) -> Payments {
6
// When creating a new Payments instance, we still need an api
7
return Payments(api: ???, provider: paymentProvider)
8
}
9 }
We still need API from somewhere. But, it wouldn’t make sense to add API as a parameter to
makePayments(paymentProvider:) because Marketplace can’t supply it.
Conversely, Main can not supply a PaymentProvider, but, it can supply an instance of API during
setupMarketplace() in Main.
As a result, Main can pass API to the factory on creation via its initializer. Let’s update the factory to
reflect this change.
Now whenever Marketplace calls makePayments(paymentProvider:) the factory creates a fresh
Payments instance.
158
Mobile System Design
1 final class PaymentsFactory {
2
private let api: API
3
4
// We can pass an API instance when creating this factory
5
init(api: API) {
6
self.api = api
7
}
8
9
func makePayments(paymentProvider: PaymentProvider) -> Payments {
10
// When creating a new Payments instance, we use both the
preloaded API and the freshly passed paymentProvider.
11
return Payments(api: api, provider: paymentProvider)
12
}
13 }
Notice how Payments receives API from the factory itself. It receives a PaymentProvider via the
parameter in makePayments(paymentProvider:).
Thanks to this solution, Marketplace does not need to know about API, thus avoiding the ABC
problem once again.
Now whenever Marketplace needs a new Payments, it can pass a PaymentProvider to get one.
To apply this to your own code, figure out what can be prepared when setting up dependencies, and
figure out what you can pass later.
Try to add to the initializer what you can. Anything that you can pass later becomes a factory method’s
arguments.
Let’s finish up this solution by integrating the factory.
7.7.2 Using the factory
Taking a look inside Marketplace, we can see how it uses a PaymentsFactory to create a Payments
type.
During initialization, we pass a CourseAPI and a PaymentsFactory, which are its direct dependencies.
Note that Marketplace offers a predefined, hard-coded, list of payment providers. When creating a
Payments instance, we need to pass one of these providers (as selected by the user).
159
Mobile System Design
1 final class Marketplace {
2
3
// Marketplace only depends on its direct dependencies.
4
// There are no transitive dependencies such as API.
5
private let courseAPI: CourseAPI
6
private let paymentsFactory: PaymentsFactory
7
8
/// We pass the CourseAPI and paymentsFactory dependencies
9
init(courseAPI: CourseAPI, paymentsFactory: PaymentsFactory) {
10
self.courseAPI = courseAPI
11
self.paymentsFactory = paymentsFactory
12
}
13
14
// Marketplace offers a default list of payment providers.
15
static let paymentProviders: [PaymentProviders] {
16
[PaymentProvider.creditcard,
17
PaymentProvider.paypal,
18
PaymentProvider.inAppPurchases]
19
}
20
21
// When we need to initialize a new Payments instance, we pass a
selectedProvider that a user has selected.
22
func makePayments(selectedProvider: PaymentProvider) -> Payments {
23
return paymentsFactory.makePayments(provider:
selectedPaymentProvider)
24
}
25
26
// Marketplace also uses CourseAPI. For example, to fetch courses
using fetchCourses
27
// This is for demonstration purposes. We can assume we already
implemented this method on CourseAPI.
28
func fetchCourses() async throws {
29
// A limit and offset allows us to page through courses
30
await try courseAPI.fetchCourses(limit: 50, offset: 0)
31
32
// ... rest omitted.
33
34 }
160
Mobile System Design
The only thing we need to do now is to update setupMarketplace() and make sure it uses
PaymentsFactory.
Instead of creating a Payments instance, we create an instance of PaymentsFactory and pass it to
Marketplace.
1 class Main {
2
3
func setupMarketplace() -> Marketplace {
4
// We set up the same instances as before.
5
let fileStorage = FileStorage()
6
let store = Store<Data>(storage: fileStorage)
7
8
#ifdef DEBUG
9
let network = StagingNetwork()
10
#else
11
let network = ProductionNetwork()
12
#endif
13
14
let api = API(network: network, store: store)
15
16
let tutorAPI = TutorAPI(api: api)
17
let todoList = TODOList(api: api)
18
let calendar = Calendar(api: api)
19
20
let courseAPI = CourseAPI(tutorAPI: tutorAPI, todoList:
todoList, calendar: calendar)
21
22
// But here we introduce a payments factory, that depends on
api.
23
let paymentsFactory = PaymentsFactory(api: api)
24
25
// We pass the factory to marketplace
26
let marketplace = Marketplace(courseAPI: courseAPI, factory:
paymentsFactory)
27
return marketplace
28
}
29
30
// ... snip
31 }
We have set up quite an extensive dependency tree for a feature.
Our solution to the ABC problem now includes support for lazy initialization, dependency instantiation
during setup, and handling of dependencies at deeper levels..
161
Mobile System Design
7.8 Closing thoughts
With a little rethinking, we set up our dependencies for the Course feature.
We laid down a foundation that suited for dependencies on a feature-sized level.
Since the scale at the moment is small, we are only setting up dependencies in a single location, such
as Main. But once our app grows bigger, we can’t always prepare all dependencies at the starting-point
of our app.
Next, it’s time to tackle app-wide dependencies, which means it’s time to increase the complexity.
In the next chapter, we’ll scale up the dependency-tree, and reason about how to handle dependencies
on a larger scope.
162
Mobile System Design
7.9 What we covered
In this chapter, we covered:
• Passing dependencies around in a boring way is most-commonly easier to understand than fancy
solutions.
• Try to consolidate compiler-flags to make it easier to reason about a codebase.
• The entry-point of an app, such as Main, is a good location to set up dependencies and configurations.
• Transitive dependencies are the dependencies of our dependencies. In other words, deeper
dependencies.
The ABC Problem
• The ABC problem is where dependencies are aware of transitive dependencies.
• Transitive dependencies are the dependencies of our dependencies. In other words, deeper
dependencies.
• Solving the ABC problem means that types are unaware of their transitive dependencies. This
promotes decoupled code.
• To solve the ABC problem, you need to flip the hierarchy upside down.
• An upside down dependency hierarchy is similar to how you would express it in code.
• The dependency tree can be set up when a type has access to the required dependencies.
• The type that sets up the dependency tree breaks the ABC rule.
Lazy instantiation and factories
• You can use lazy instantiation, or a factory, to handle dependencies that you can’t create during
the set up phase of your app.
• You can use lazy instantiation, or a factory, if you don’t want to set up dependencies when the
app is set up.
• Preload a factory with the dependencies that you can via an initializer.
• The dependencies that are available later, can be passed as arguments to a factory method.
163
8 Dependency Injection on a larger scale
In this chapter
• Breaking down large dependency trees
• Figuring out where to put lazy dependencies in larger hierarchies
• Finding a balance between breaking the ABC rule versus injection techniques
• Setting up dependencies for an entire app
• Passing dependencies across module boundaries in a modular app
There is a truth to the fact that dependencies become complex once a codebase grows. Which is usually
when people stop calling it “passing values around” and start calling it “Dependency Injection”.
Once your app reaches a certain scale, or once you need to align with a larger team, you may reach for
a formalized way of handling dependencies. Otherwise, people might have to update a bazillion files
or use shortcuts (singletons) to pass a dependency around.
However, a little team-alignment can go a long way, and not all common issues require an opinionated
third-party framework.
We’ve covered how to solve dependencies on a smaller scope for a single feature. We went over the
ABC problem to make sure that types do not know about their transitive dependencies.
However, once our codebase grows, it can become complicated again.
The good news is that we can handle a large amount of dependencies similarly as before. With the
same approach, we can not only solve the ABC problem, but even larger ones, such as an A to Z problem.
A situation where every letter (dependency) can not know about any dependencies in between.
But, it might be less straight-forward. Our current solution will start showing its cracks and we need
more tools to deal with dependency challenges on a larger scale. To handle this, we’ll use this chapter to
convert a large dependency tree into smaller bite-sized dependency-trees that are easier to handle.
When working on a larger scale, we can still work on a single, local app. But, sometimes we are dealing
with module boundaries, where we have to pass dependencies between modules.
Surprisingly, passing dependencies across modules has a lot of nuances related to designing the public
interface of a module. This chapter will go in depth about these topics so that you can better design
your modules.
165
Mobile System Design
8.1 Considering a common approach
Before we apply our own solution, let’s consider an alternative, common solution.
A common way to pass around many dependencies across an entire app is to create a sort of
AppContext container. These containers are classes (or structs) that contain commonly shared
dependencies. A team would then pass this container around almost everywhere.
The idea behind this is that it keeps other initializers small, because you need only pass a single
dependency (a giant container) initializers.
A benefit of this approach is that once you introduce a new dependency, you only need to update the
container. There is no need to make factories or weave dependencies around.
But one trade-off with this approach is that you’re indirectly passing every important dependency to
almost every type.
It makes it harder to figure out the dependencies a feature needs. Because, by looking at an initializer,
you can’t instantly see the dependencies a type uses.
Another issue is that it can make it too easy to slap new dependencies on the container, as opposed to
applying “regular” DI. Thus, making this container massive over time.
Whichever way you go about it, this approach will reach some limits once it grows. It can trigger people
to get creative trying to deal with a large container.
For instance, you can split off this container into smaller containers the deeper you go in the class
hierarchy. This can be a valid strategy.
Others might make the container conform to interfaces. This way, you’re dealing with interfaces that
contain a subset of properties from the container. But, even though it seems like you’re dealing with
fewer properties, everyone is still passing a giant container around.
When using interfaces to hide a large container, the problem of a large amount of dependencies remains,
it’s just swept under the rug.
In other cases, a large container can become a bottleneck. Almost the entire app will rely on it, which
means all teams rely on it in the entire company.
It can definitely be very practical and straightforward to use. From a purist view, however, it’s close to
a singleton that you’re passing around, and we can consider it a shortcut.
Now this container will need safety mechanisms to protect its integrity. We have to add synchronization
points or locking mechanisms to the container to make it safer.
It’s a global state, packaged as a large class.
166
Mobile System Design
Let’s continue with the approach we have been taking that we can lovably call “passing values
around”.
8.2 Handling larger dependency trees
Next, let’s consider how to handle dependencies when the hierarchy gets bigger and more complicated.
Let’s expand the settings on a large app. Large apps usually have tons of features and screens, and
that infers a lot of settings.
The next graph represents each screen in a Settings environment, but for simplicity, we leave out
the screen dependencies.
It’s not important that you know about all features. The core idea is that we have a lot of dependencies
to handle.
Like most things in programming, we can solve a complex problem by breaking it down into smaller
problems and solve those. Then we use these smaller solutions to solve the bigger problem.
To wrap our heads around a large tree, we’ll cut off branches into sub-trees. The goal would be to
reduce these sub-trees into their respective setup methods. Then we’ll use these sub-trees to solve the
giant dependency tree.
8.2.1 Reducing a sub-tree
Let’s grab one sub-tree. We can work from left-to-right, meaning we start with Monetization.
167
Mobile System Design
Then we’ll set up the dependencies and reduce the Monetization sub-tree into a single
makeMonetizationSettings() method.
First, we’ll zoom in so we expose all its dependencies.
To make the graph complete, we’ll add the dependencies that the screens need.
NOTE: Screens are marked with a double outline to tell them apart from regular types.
Looking at Monetization’s dependencies, we can tell there are two transitive dependencies.
First, Monetization has no need for API, but its dependency PaymentSettings does. We can either
break the ABC rule or make a PaymentSettingsFactory. We’ll be a DI purist for the Monetization
hierarchy, and we’ll introduce a PaymentSettingsFactory.
Second, PaymentProviders has no need for Store or User, but its dependency ProviderLinks
does. That means we’ll introduce a ProviderLinksFactory.
168
Mobile System Design
8.2.2 Settings as a setup location
Settings may not need to open Monetization for every session. It can have an openMonetization
() method that’s only called when needed. This would already make it lazy instantiation.
Calling openMonetization would work if Settings has all the dependencies it needs. But, notice that Settings itself also has transitive dependencies. It doesn’t directly use any of the dependencies of Monetization, such as PaymentSettings. That means that Settings will need
a MonetizationFactory.
But, to keep our code simpler, we’ll break the ABC rule and we’ll pass the dependencies to Settings
that are used by the screens. These dependencies are API, Store, and User.
169
Mobile System Design
Since this is a core location that sets up many classes, we can deem it as acceptable.
That concludes the design for all Monetization’s settings screens. The entire hierarchy of
Monetization is semantically correct; None of its (transitive) dependencies know about any
transitive dependencies. Thus avoiding the ABC problem.
The only compromise we made was that Settings is aware of the transitive dependencies.
8.2.3 Expressing the dependencies in code
To be complete, we’ll express this dependency-tree in code. But note however, that it’s using no new
principles.
We are passing values around and creating factories, just like before. The result is a boring piece of code.
That’s the point, it’s supposed to be boring and straight-forward, making it accessible to others.
NOTE: All these classes would normally be filled with features and content, but for educational purposes,
we’ll only depict the values that are passed around.
First, Settings receives the dependencies used by its transitive dependencies. As we covered, this
class breaks the ABC rule since it’s a place where a lot of dependencies are configured.
NOTE: Luckily, when expressing this in code, you don’t have to think too hard about it. Because the
compiler will instantly tell you where a dependency is missing.
1 final class Settings {
2
private let user: User
3
private let store: Store
4
private let api: API
5
6
init(user: User, store: Store, api: API) {
7
self.user = user
8
self.store = store
9
self.api = api
10
}
11
12
func setupMonetization() -> Monetization {
13
// Set up PaymentSettings
14
let providerLinksFactory = ProviderLinksFactory(user: user,
store: store)
15
let paymentProviders = PaymentProviders(api: api,
providerLinksFactory: providerLinksFactory)
16
let paymentSettingsFactory = PaymentSettingsFactory(api: api,
paymentProviders: paymentProviders)
17
18
// Set up Monetization
19
let monetization = Monetization(user: user, store: store,
paymentSettingsFactory: paymentSettingsFactory)
170
Mobile System Design
20
21
22
23
24
25 }
}
return monetization
//... rest omitted
Looking inside Monetization, we see that receives its dependencies. The only exception is
PaymentSettings. It uses a PaymentSettingsFactory, since it has transitive dependencies that
Monetization shouldn’t know about.
1 final class Monetization {
2
private let user: User
3
private let store: Store
4
private let paymentSettingsFactory: PaymentSettingsFactory
5
6
init(user: User, store: Store, paymentSettingsFactory:
PaymentSettingsFactory) {
7
self.user = user
8
self.store = store
9
self.paymentSettingsFactory = paymentSettingsFactory
10
}
11
12
func openPaymentSettings() {
13
let paymentSettings = paymentSettingsFactory.
makePaymentSettings(user: user, store: store)
14
// display paymentSettings
15
}
16
17
//... rest omitted
18 }
Next, PaymentSettingsFactory, which doesn’t need any factories itself.
1 final class PaymentSettingsFactory {
2
private let api: API
3
private let paymentProviders: PaymentProviders
4
5
init(api: API, paymentProviders: PaymentProviders) {
6
self.api = api
7
self.paymentProviders = paymentProviders
8
}
9
10
func makePaymentSettings(user: User, store: Store) ->
PaymentSettings {
11
PaymentSettings(api: api, user: user, paymentProviders:
paymentProviders)
12
}
13
14
// ... rest omitted
171
Mobile System Design
15
16 }
Next, PaymentSettings, which has no factories either.
1 final class PaymentSettings {
2
private let api: API
3
private let user: User
4
private let paymentProviders: PaymentProviders
5
6
init(api: API, user: User, paymentProviders: PaymentProviders) {
7
self.api = api
8
self.user = user
9
self.paymentProviders = paymentProviders
10
}
11
12
// ... rest omitted
13 }
Then, PaymentProviders. This one also depends on a factory because of its transitive dependencies.
1 final class PaymentProviders {
2
private let api: API
3
4
let providerLinksFactory: ProviderLinksFactory
5
6
init(api: API, providerLinksFactory: ProviderLinksFactory) {
7
self.api = api
8
self.providerLinksFactory = providerLinksFactory
9
}
10
11
func makeProviderLinks() -> ProviderLinks {
12
let providerLinks = providerLinksFactory.makeProviderLinks(api:
api)
13
return providerLinks
14
}
15
16
// ... rest omitted
17 }
Finally, the ProviderLinksFactory and ProviderLinks.
1 final class ProviderLinksFactory {
2
private let user: User
3
private let store: Store
4
5
init(user: User, store: Store) {
6
self.user = user
7
self.store = store
8
}
172
Mobile System Design
9
10
func makeProviderLinks(api: API) -> ProviderLinks {
11
ProviderLinks(api: api, user: user, store: store)
12
}
13 }
14
15 final class ProviderLinks {
16
private let api: API
17
private let user: User
18
private let store: Store
19
20
init(api: API, user: User, store: Store) {
21
self.api = api
22
self.user = user
23
self.store = store
24
}
25
26
// ... rest omitted
27 }
8.2.4 Grouping the setup methods
That concluded one tree inside settings. If you were to keep going, you’d not only have
setupMonetization() in Settings, but you’d end up with a setup method for each branch.
To set up the entire tree, Settings we offer a configureSettings() method that calls up
Monetization as well as all its other sub-tree branches.
1 final class Settings {
2
private let user: User
3
private let store: Store
4
private let api: API
5
6
init(user: User, store: Store, api: API) {
7
self.user = user
8
self.store = store
9
self.api = api
10
}
11
12
func configureSettings() {
13
setupMonetization(user: user, api: api, store: store)
14
setupSecurity(user: user, store: store)
15
setupPrivacy(user: user, api: api)
16
setupDirectMessages(store: store)
17
setupDiscoverability()
18
setupData(api: api, store: store)
19
setupLocation(user: user, api: api)
20
setupPrivacy(user: user, api: api, store: store)
21
setupSupport()
173
Mobile System Design
22
23
24
25
26
27 }
}
setupPushNotification(user: user, api: api, store: store)
setupLogin(user: user, api: api, store: store)
// .. rest omited
By combining setup methods, we have a core location where we set up all the dependencies for
Settings.
Seeing this boilerplate, your brain might itch, and it may want to come up with some sort of generic
solution. But, we intentionally don’t. This is a boring setup location, let it be boring and simple!
8.3 Dependencies across an entire app
Setting up all dependencies in one location can make it easier to reason about a codebase, but it
doesn’t always scale.
In a real-world application, it will rarely work this way. Dependencies are often created at multiple
locations, not only at their entry-points, such as Main.
Throughout this chapter, we’ve been using setupMarketplace() in Main as an example. But we
often set up certain dependencies deeper in the hierarchy – even in other modules. It depends on what
dependencies a class has available to set up other dependencies.
Just now, we intentionally broke the ABC rule so that the settings tree was easier to set up.
We can take this idea further. By mixing factories with breaking the ABC rule at strategic locations,
we can configure dependency trees of a giant app. This way, it stays relatively easy to reason about
dependencies.
8.3.1 Multiple setup locations
Imagine we have a Main app entrypoint, which contains a TabBarCoordinator. This coordinator
glues together all features that are available after logging in. This includes Marketplace and other
subdomains, such as Course, or Settings.
In that case, some of the code from the setupMarketplace() method can live in TabBarCoordinator
. This works, as long as TabBarCoordinator has all the required dependencies, such as API and
Store.
From there, other dependencies can set up their dependencies.
174
Mobile System Design
This way, we move some of the DI setup to their respective locations.
8.3.2 Downsides of our approach
One thing to keep in mind when breaking the ABC rule is that once you pass a new type to a transitive
dependency, you now have to update the setup locations.
For instance, let’s say we need to add a new dependency to Settings. Now we also have to update
TabBarCoordinator, since that contains the setup method for Settings.
This problem goes for any setup location in the app. However, that can be a good trade-off to make to
keep the DI simple, and keep it “pure” everywhere else.
175
Mobile System Design
8.4 Passing dependencies across a modular app
We’ve covered app-wide dependencies. But, let’s go even bigger.
Developers tend to split larger apps up into various modules that contain isolated features. The app
would then embed these modules.
We’ll cover modular apps in depth in upcoming chapters. But when handling dependencies, we need
to be aware of how it affects dependencies.
When we handle dependencies across modules, a new, important factor comes into play, which is the
public interface.
We have to consider how to keep the public interface small, and how to reduce tight-coupling between
two modules, or between an app and a module.
NOTE: We’ll cover public interface design in-depth later in the book. For now, it’s good to know that
exposing a lot of types from a module increases the chance of tight-coupling between a module and its
implementer. This makes it harder to keep a module stable or move parts around independently.
Let’s discover the problems we run into once we modularize an app and how to handle them.
176
Mobile System Design
8.4.1 A modular app
Let’s assume we have a Course module containing Marketplace, CourseAPI, and related domains.
We can choose to put the networking functionality, such as API and Network, in another module,
called the Communication module. It can live in its own module since it contains foundational code
that can support many other features, not just Course.
The app embeds the Course module, which depends on the Communication module.
NOTE: We use a simplified representation as a starting point to better explain the concepts. We have
omitted smaller types and their connections.
Next, we’ll update our code to match this change. Inside the app, we would import the Course and
Communication modules. But the rest remains the same.
177
Mobile System Design
We use the import keyword to include code from other modules. In this case, the Course and
Communication modules.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// This code lives in the app
// The app now depends on the Course and Communication modules
import Course
import Communication
class Main {
func setupMarketplace() -> Marketplace {
// Everything else is the same as before
let storage = MemoryStorage()
// ... omitted since it's the same as before.
}
}
let marketplace = Marketplace(courseAPI: courseAPI)
return marketplace
// ... rest omitted
But, there is now a hidden issue related to public types. Perhaps you already have an idea what the
problem is?
8.4.2 A spider in the web
Previously, to solve the ABC problem, we built the Marketplace bottom-up. That means that, in
order to create an instance of Marketplace, we first started by initializing a Store and Network, after
which we created the rest.
However, with this modular configuration we have an issue; All the types from setupMarketplace()
now live in modules. But, because the app sets up all dependencies, all dependencies have to be
public, so they’re exposed to the app.
NOTE: Being public means that these types are available outside of the modules.
Looking inside the Course module, we have to make the following types public; TutorAPI, TODOList,
Calendar, CourseAPI, and Marketplace. These have to be all public types because the app sets
them up.
Looking at the Communication module, we can see that the app is creating instances of API, Store,
MemoryStorage, StagingNetwork, and ProductionNetwork, These have to be public too.
Because of that, the app becomes a spider in the web. It knows about everything.
178
Mobile System Design
This tight coupling happens for two reasons: First, we are exposing too many types. Second, the app
has the responsibility to set up all dependencies.
We worked hard to ensure types did not know about their transitive dependencies.
But, ironically, this creates tight coupling on a module-level.
To solve this particular problem, we need to make the app aware of a module’s transitive (deeper)
types. Let’s see how.
8.4.3 Reducing tight-coupling between modules
There is a way to reduce the "spider in the web" problem.
It would be nice if we could just move the entirety of setupMarketplace() to the Course module.
Unfortunately, that will not work. Because the app will determine the network and storage, which
means the app needs to pass, or inject, those.
However, we can set up the Course module so that the app does not know about dependencies inside
of it.
First, we need to find the highest dependency below the Course module.
179
Mobile System Design
By looking at the graph, we see the API class is closest to the bottom of the Course module hierarchy.
We see that API is the direct dependency of the Course module. For that reason, we make sure the
app passes an instance of API to the Course module.
By having the app pass API, the app does not need to know about all dependencies inside the Course
module. As you’ll witness soon, this reduces the tight-coupling, and setting up the dependencies
becomes easier for the app.
8.4.4 Expressing a solution in code
Since API is the direct dependency of the Course module, that’s all the app needs to pass to the
Course module to create a Marketplace instance.
Inside the app, we decided to only pass an API instance to create a Marketplace instance.
180
Mobile System Design
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// This is in the app
import Course
import Communication
class Main {
func setupMarketplace() -> Marketplace {
// To set up API, nothing changed.
let storage = MemoryStorage()
let store = Store<Data>(storageType: storage)
#ifdef DEBUG
let network = StagingNetwork()
#else
let network = ProductionNetwork()
#endif
let api = API(network: network, store: store)
22
23
24
25
26 }
}
// NEW: Now we only pass an api instance to initialize
Marketplace
return Marketplace(api: api)
// ... rest omitted
The next thing left to do is to update Marketplace so that it accepts an API instance. But, we risk
re-introducing the ABC problem. Let’s cover how to handle that.
8.4.5 The ABC problem appears again
Next, we go into the Course module.
From the app, we pass an API instance to Marketplace, so we need to update its initializer.
But, we see that it’s a naïve solution.
1
2
3
4
5
6
7
8
9
10
// This is inside the Course module
import Communication
// Marketplace is now public
public final class Marketplace {
let courseAPI: CourseAPI
// The initializer is public too, so that the app can use it.
public init(api: API) {
// Naive solution! We are passing a transitive dependency
181
Mobile System Design
11
12
13
14
15
16
17
18
19
20 }
let tutorAPI = TutorAPI(api: api)
let todoList = TODOList(api: api)
let calendar = Calendar(api: api)
}
// Using API, we can initialize a CourseAPI instance.
self.courseAPI = CourseAPI(tutorAPI: tutorAPI, todoList:
todoList, calendar: calendar)
// ... rest omitted
NOTE: We make Marketplace and its initializer public, so that it is accessible outside of the domain.
This allows the app to access Marketplace. The same goes for the types inside the API domain (not
depicted).
But oops, we re-introduced the ABC problem. Notice how we pass API to the Marketplace initializer,
yet Marketplace does not use it directly.
Marketplace is aware of a transitive dependency, breaking the ABC rule.
8.4.6 Solving the ABC problem across module bounds
There are a couple of ways to handle this.
One way is that we can accept this trade-off. We have a smaller public interface at the price of
Marketplace knowing about its transitive dependencies. We could state that keeping a public interface small is more important.
But, we can clean this up. We do this by thinking about responsibilities.
Let’s ask ourselves: Is setting up dependencies really Marketplace’s responsibility?
Instead of setting up dependencies in the initializer of Marketplace, we can offer a setup function in
the Course module. This function can live outside of Marketplace.
Since we don’t need state from a class instance to create Marketplace, we can offer a static function
that’s disconnected from classes. This function can serve as a setup function, or factory.
NOTE: A static function is a function that can be called globally or on a type itself, as opposed to an
instance of a type.
Since this static function can live anywhere, let’s attach it to namespace. To fake a namespace in Swift,
we can use an enum. Let’s name the enum after the module itself, namely Course, and add a static
function there called makeMarketplace.
1 // This is inside the Course module
182
Mobile System Design
2 import Communication
3
4 public enum Course {
5
6
// This is a static function
7
public static func makeMarketplace(api: API) -> Marketplace {
8
// We can instantiate CourseAPIs dependencies as before.
9
let tutorAPI = TutorAPI(api: api)
10
let todoList = TODOList(api: api)
11
let calendar = Calendar(api: api)
12
13
// And we can pass the types to CourseAPI.
14
let courseAPI = CourseAPI(tutorAPI: tutorAPI, todoList:
todoList, calendar: calendar)
15
// And we use CourseAPI to initialize Marketplace
16
return Marketplace(courseAPI: courseAPI)
17
}
18
19 }
We disconnected the Marketplace creation from its instance. This ensures that Marketplace itself
does not know about API.
Because makeMarketplace(api:) is a static function, it’s really easy to move around.
For example, for discoverability, we can choose to place this method on Marketplace itself. But
this time, we make sure that it remains a static function, to ensure Marketplace instances remain
oblivious to API.
1 // This is inside the Course module
2 import Communication
3
4 public final class Marketplace {
5
6
// It's not an instance method. Marketplace instances are *not*
aware of API
7
8
// Notice how this is now a static function
9
public static func setupMarketplace(api: API) -> Marketplace {
10
let tutorAPI = TutorAPI(api: api)
11
let todoList = TODOList(api: api)
12
let calendar = Calendar(api: api)
13
14
let courseAPI = CourseAPI(tutorAPI: tutorAPI, todoList:
todoList, calendar: calendar)
15
return Marketplace(courseAPI: courseAPI)
16
}
17
18
// ... rest omitted
19 }
183
Mobile System Design
The last thing to do, is to update the app so that it uses the factory method.
1
2
3
4
5
6
7
8
9
10
// This is inside the app
// This...
return Marketplace(api: api)
//... becomes
return Marketplace.setupMarketplace(api: api)
// Or, alternatively...
return Course.makeMarketplace(api: api)
Since the module itself now takes care of settings up its own dependencies, it makes it more portable
and easier to set it up in any location.
8.4.7 A cleaner graph
Looking at the graph, we can see the app now only knows about Marketplace from the Course
module. Marketplace is the only type inside the Course domain that needs to be made public. As a
result, the tight-coupling between the app and Course module disappears.
184
Mobile System Design
By reducing the tight-coupling, it’s easier to work with the codebase. We have more flexibility; Let’s say
we want to move this module out of the app’s workspace into its own repository; It’s now easier to do
so.
The graph is looking much better. However, the app is still aware of all types inside the API module,
which we’ll handle next.
8.4.8 Reducing tight-coupling for foundational modules
In practice, it’s common for types in foundational modules to be public. Such as API, and Store.
However, you can choose to reduce the tight-coupling between the app and API module even more.
We achieve this by passing only the lowest types in the hierarchy. This cleans up the tight-coupling
considerably. But, as you’ll soon discover, it won’t keep its public interface small.
We can choose to have the app pass a StorageType, such as MemoryStorage, and a Network, such
as ProductionNetwork or StagingNetwork, depending on the environment.
Then, the Course module can build up the entire dependency tree.
Now, setting up the Marketplace becomes even simpler from inside the app.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Course
import Communication
// This is in the app
class Main {
18
19
20
21 }
func setupMarketplace() -> Marketplace {
let storage = MemoryStorage()
#ifdef DEBUG
let network = StagingNetwork()
#else
let network = ProductionNetwork()
#endif
}
// We now pass storage and network
return Marketplace.setupMarketplace(storage: storage, network:
network)
// ... rest omitted
185
Mobile System Design
The remaining code now moves to the setupMarketplace(network:, storageType:) function
inside the Course module.
1 // This is inside the Course module
2
3 import Communication
4
5 final class Marketplace {
6
7
// The setupMarketplace function now accepts the Network and
StorageType interfaces instead of API
8
static func setupMarketplace(network: Network, storageType:
StorageType) {
9
// Then we can initialize the Store and continue as before.
10
let store = Store<Data>(storageType: storage)
11
12
// We set up the API using the passed network.
13
let api = API(network: network, store: store)
14
15
let tutorAPI = TutorAPI(api: api)
16
let todoList = TODOList(api: api)
17
let calendar = Calendar(api: api)
18
19
let courseAPI = CourseAPI(tutorAPI: tutorAPI, todoList:
todoList, calendar: calendar)
20
return Marketplace(courseAPI: courseAPI)
21
}
22
23
// ... rest omitted
24 }
186
Mobile System Design
Looking at the graph, we can see that we cleaned up the dependencies considerably.
Most of the setup code moves to the Course module. We reduced the tight-coupling considerably. But
unfortunately, we can’t reduce the public interface.
API, Store, Network, and StorageType still need to be public, since that’s used by the Course
module.
We managed to clean up the tight-coupling between API and the app, but we did not minimize the
public interface of the Communication module, which we did manage to do for the Course module.
This is because the public interface of foundational modules are often harder to keep small. Foundational modules lower in the stack, such as Communication, tend to have lots of types that need to
be public to support other features. Which means the public interface tends be bigger than those of
feature modules.
Conversely, the public interface of feature modules is often easier to keep small. For instance, from the
Course module, we only made the Marketplace public, nothing else.
Another way to think about it; The Communication module is on a lower abstraction, lower in the
187
Mobile System Design
hierarchy. Types lower in the hierarchy tend to contain more "building blocks", or "primitives" to
support features on top of it.
Features on top of foundational components are often more specialized or opinionated. Such as
Course module offering an entire flow. Course isn’t a building block for tons of other features. Hence
why its public interface can remain smaller.
8.5 Closing thoughts
Let’s take a moment to appreciate how far we got without fancy tricks.
We didn’t use a custom library we wrote ourselves; we didn’t use third-party frameworks, and we
didn’t rely on singletons. We didn’t use complicated methods like service locators, aspect-oriented
programming, or clever subclass overrides.
We didn’t even use language-specific or platform-specific features from Swift, iOS, SwiftUI, or UIKit;
such as property wrappers, swizzling, or relying on @EnvironmentObject.
We are merely passing things around in a very boring way.
Boring makes code easier to understand for all kinds of engineers in your company.
This is a big win.
A benefit of all these dependencies being consolidated in a setup method is that it’s clear where to add
a new dependency.
Consolidating all dependencies in setup methods makes it easy for the engineer to find the location of
all other dependencies and add their own.
TIP: Since our approach isn’t Swift-specific or iOS-specific, we can apply this “boring” technique to a large
variety of programming languages and platforms!
8.5.1 Trade-offs
A downside is that it might be more work to weave in a dependency once the hierarchy becomes really
complex.
This is not a problem unique to the “regular passing of values” approach. But it’s important to keep
in mind that it should be clear where we can find dependencies, and how we can add them to our
own work. Good documentation goes a long way. Otherwise – because of time constraints or other
difficulties – people in a team break the ABC rule or will introduce singletons.
188
Mobile System Design
Breaking the ABC rule is not the end of the world, since you are still passing your dependencies. Luckily,
refactoring our code to avoid the ABC problem can be straight-forward, thanks to the help of the
compiler telling us where a property is missing.
189
Mobile System Design
8.6 What we covered
In this chapter, we covered:
• You can use a large container to pass dependencies throughout an entire app.
• An app container can become a bottleneck, and is similar to a singleton that’s passed around.
• An app container will need synchronization mechanisms to more it matures.
• An app container can help prevent large initializers.
• It’s easy to add new dependencies to an app container, since you don’t have to update any
initializers in the rest of the app.
• You can split up an app container into smaller containers. So that you can pass subsets of the
large container around, deeper in the hierarchy.
• People use interfaces to use a subset of an app-container. But, teams will still pass a container
around, it’s just hidden.
Large dependency trees
• If you’re dealing with a large dependency-tree, try to break it down into sub-trees, and handle
dependencies for sub-trees.
• Once you dealt with a giant sub-tree, move on to the other sub-trees.
• At the top of a large hierarchy, you can set up multiple sub-trees side-by-side.
Dependencies throughout an entire app
• Use key locations to break the ABC rule, so that types do know about their transitive dependencies.
This makes setting up dependencies easier.
• However, try to limit these key locations.
• Throughout the rest of an app, avoid breaking the ABC rule to keep types decoupled.
Dependencies across a module
• When passing dependencies across a module, keep in mind how it impacts the public interface
of a module.
• If a location outside of a module sets up all dependencies, you risk having to make a lot of types
public.
• If a location outside of a module sets up all dependencies, you risk tight-coupling between a
module and its implementer, such as an app or another module.
• A lot of public types make it harder to maintain a module and to keep it stable.
• To set up a module’s dependencies, find the dependency that a module needs, and pass that.
This way, you don’t have to make many types in the module public.
190
Mobile System Design
• Try to let a module set up its own hierarchy. You can use static functions for this that can easily
be moved around.
• By using a static function, a class instance becomes unaware of dependencies, thus avoiding the
ABC problem.
• To minimize the tight-coupling between a foundational module and its implementers, you can
make the elements public that are lowest in the stack.
• Minimizing a foundational module’s public interface is difficult. This is because foundational
modules usually have more general types that serve as building blocks for features. These types
often need to be public.
• Compared to foundational modules, a feature module’s public interface is easier to keep smaller.
Since it makes more assumptions and serves less than a building block for other features.
191
9 UI frameworks, architectures, and supporting
multiple products
In this chapter
• The benefits you get when implementing UI at a later stage.
• Why UI Architectures are seasonal and less important than you might think.
• How to force yourself to decouple business logic from UI.
• The benefits of imagining your app as a Command Line Tool.
• Why UI architectures shouldn’t dictate the architecture of business domains.
We’ve been going over many steps to create a feature from scratch; First, we were briefed, after which
we designed APIs and components in the business domains.
The topics we covered so far include; testing, data modeling, domain modeling, reasoning about
abstractions, multi-environment support, and dependency management.
But, we haven’t delivered a single UI component. This might seem strange to you, because usually,
mobile developers will start with UI.
Yet, here we are, saving the UI for last, like dessert.
Saving UI for last is unorthodox, but it has a reason. In this chapter, we’ll cover in depth the benefits it
brings.
In the upcoming chapters, we’ll implement UI. Before we do so, we take a brief detour to cover a
common topic people ask themselves at this stage, which is “What UI framework and architecture
should I use?”.
NOTE: When mobile engineers talk about architectures, they often mean UI architectures. As opposed
to, say, architectures that apply to business or foundational domains, such as the API or Calendar
domains in this book.
In the mobile development community, we talk a lot about “the best patterns”, or why “declarative UI
should (not) have viewmodels”. But, we will reason about our app so that these questions become less
important.
193
Mobile System Design
Historically speaking, how we set up our UI often dictates the rest of the app. But the goal of this
chapter is to take a different approach and show you that the UI layer can be less important than it’s
advertised.
Of course, when working with specific architectures, specific details do matter. For instance, the way
you set up your code in a reactive codebase differs vastly from a “regular” imperative codebase. It
affects your day-to-day work, and as a result, how you design your structure.
But, we aren’t married to architectures, and they are not forever ever after.
Software changes rapidly, and as soon as the next UI architecture comes out, you may find yourself in
a major rewrite.
In this chapter, we’ll reason about our code to ensure we’re not “fully locked in” to a UI architecture.
This ensures you properly detach the UI architecture from the business logic.
A benefit is that you will be more nimble for when you are ready to change architectures, or even
support entirely various new platforms.
In this book, we’ll focus on core UI principles, as opposed to specific architectures and trending
patterns.
You can always apply these principles regardless of what you’re using. This way, you’ll be better
prepared for any architecture today and tomorrow.
9.1 UI Principle 1: Defer implementing the UI
It’s time to come clean.
Throughout the chapters, this book has been trying to refactor your brain. It’s trying to get rid of the
whole UI-first programming that the mobile industry indoctrinates upon us unassuming developers.
We merely touched upon deciding when to implement UI. I hope you see we have made countless
(micro)decisions that don’t involve UI, so that we can build our feature.
Deferring UI may seem counterintuitive, because after all, as mobile engineers we often receive UI and
are asked to implement what we see, and it’s a very visual way to show people your progress.
For small apps, making UI right away is absolutely fine and it can be a lot of fun. Inside a larger
organization or app, however, it’s usually not the highest priority; It can, in fact, be a waste of time
when requirements aren’t fully clear yet.
In the early stages, when developing an app or feature, it’s a better use of our time to understand the
requirements and domains on a deeper level. It’s tempting to dive headfirst into making UI, but we
may ignore important requirements.
194
Mobile System Design
By deferring the UI, it’s almost a detail how we build our UI at first. Until now, it did not matter whether
we use imperative programming (E.g. UIKit) or declarative programming (e.g. Jetpack Compose, or
SwiftUI). As a result, it did not matter which architecture we used.
By focusing heavily on the feature itself and making it work without UI, we gain some benefits, such
as:
• We get a better understanding of our feature and its (non functional) requirements. As opposed
to being sidetracked about UI details such as colors, shadows, and animation curves.
• We get out of our safe “UI bubble”. Instead, we focus more on teamwork with the entire team
(not just designers); such as aligning what to build first, or how the app handles errors when it
communicates with servers.
• Our business logic is automatically not present in the UI layer – forcing a separation of concerns.
• Our code is already better prepared if we decide to separate it into low-level modules (packages,
frameworks, you name it). Because there are zero “import UI” statements.
• All code we have written so far is unit-testable, because again, our business logic is living fully in
a non-UI environment.
• We can support multiple-platforms more easily if needed, because the features we built don’t
rely on a specific target.
Notice the flexibility we get. We aren’t prematurely optimizing, or making our code “reusable just in
case”. We are merely prioritizing differently by implementing UI at a later stage. As a side-effect, it
ensures we don’t let business logic leak into the UI domain.
By deferring the UI, we don’t have to use our mental power and discipline to keep the business logic
loosely coupled. There is no UI yet, so all business logic automatically lives in the right place.
9.2 UI Principle 2: UI architectures come and go
At this stage, during the feature-development process, you might wonder which paradigm, or architecture, to choose.
Often we pick the framework upfront because it dictates the platforms we use and our way of working;
UIKit vs SwiftUI, XML vs Jetpack Compose, Kotlin Multiplatform, React Native, maybe hybrid web
technologies, or a combination of these.
Next, we often wonder which architecture with fancy acronyms to use. They often are a combination
of M’s with V’s and C’s in various orders.
Will your team decide on an imperative, reactive, or declarative, approach? Or perhaps a mix? Once
your team agrees on that direction, there is the followup question, namely “which patterns should we
195
Mobile System Design
use?” Such as MVC (Model View Controller) versus MVVM (Model View ViewModel), or perhaps using a
functional style mixed with declarative, or maybe making everything reactive is the way to go. There
are tons of options and it’s a minefield of preferences.
But whether your team decides on MVC, MVVM, MVVMP, SwiftUI, UIKit, hybrid, imperative, reactive,
declarative, or a mobile clone of the Elm architecture; In the end, most popular UI architectures and
frameworks are fine to use and solve similar problems in different flavors.
However, keep it simple. You don’t need a six letter acronym to fetch and display a profile picture. It’s
like shooting a cannonball to kill a mosquito.
Consider adopting a fancy architecture only when a simple solution doesn’t suffice.
9.2.1 Consider UI architectures as alignment tools
The purpose of a UI architecture is to serve as the “glue” between the business domain and the UI
domain. Choosing between them is often a matter of preference and experience, resulting in squabbles
about which one is superior.
A healthier way to think of UI architectures is to see it as an alignment tool within a team. It makes
code more predictable to understand, so teams can focus more on delivering value.
Trendy third-party architectures are great for learning new techniques and broadening horizons. They
can definitely be worth exploring.
But, keep in mind that there is a hidden cost when you introduce third-party frameworks to a team;
They change more often than first-party frameworks. At worst, you increase the time it takes for new
members to onboard in your company.
Be aware of trends. Trending architectures will also stop being popular at one point. That means there
is a migration looming a few years down the line to the next hottest trend.
As we covered in the dependency foundations chapters; If you like a third-party framework, use it. But
be sure to consider the benefits of writing vanilla code, then you only need to worry about first-party
changes.
Because even when dealing with first-party changes, we already have to migrate a lot; Such as switching
to a new programming language, a new OS, or moving to new UI frameworks offered by Apple and
Google.
9.2.2 There is no "perfect" UI architecture
Sometimes it may seem that picking the “perfect” architecture is really important. But when problems
arise in the UI layers, it’s rarely because a team selected “the wrong” architecture.
196
Mobile System Design
UI architecture problems are more likely to occur when:
• Developers that use a complicated architecture for a small, simple app.
• Developers that mix business domains with UI domains, causing UI layers to be too tightly
coupled to business domains, making it hard to adapt, reuse, or even decouple UI components.
• Developers that jump to the latest new architecture, forcing a team to learn new paradigms,
distracting themselves from delivering value to customers.
• Developers who try to implement their preferred UI architecture onto another can create a more
convoluted codebase, which is harder for the team to understand.
Maybe your team might feel the need to implement a new architecture. Usually when they’re having a
tough time updating or adding new features. Or when they struggle to test their code, such as having to
write UI Tests to test business logic. But in that case, I’m willing to bet that the “wrong” UI architecture
isn’t the source of these problems.
Perhaps the issue you’re facing has nothing to do with UI architecture. Maybe your project has missing
documentation, missing proper examples, lack of training, a bad onboarding experience, disagreements not being resolved, or a poor code-review culture.
Chances are, the UI architecture you picked is fine for the foreseeable future.
9.2.3 UI Architectures and trends
Don’t fret if you’re afraid you picked the “wrong” architecture. There will always be a trending
architecture. People get amazing work done in any architecture.
“The best” architecture often comes down to what’s trending. Because what’s being used currently is
what people are being hired for, and that’s what we will use in newer codebases.
If you work in an older codebase, expect a mix of older and newer architectures. People might mix and
match or try new architectures and patterns as experiments. Now you’ll need to understand various UI
architectures.
But as long as you keep the business logic out of the UI domain as much as possible, then the “core
app” isn’t too married to the UI architecture. This makes the UI leaner and easier to refactor. It makes
it easier for you to deal with any changes. Such as supporting new paradigms or even entirely new
platforms, such as Mobile VR.
197
Mobile System Design
9.2.4 Architectures can be formed over time
Architectures will most likely differ per feature or domain. For example, in this book, the TODOList
domain is imperative, while the API domain may use functional programming for parsers, and yet the
UI domain might use a declarative approach.
Then once you hook a feature into a larger app, you might use another architecture to connect it all
together.
It’s normal and very common to see an app that evolves over time, consisting out of various architectures across various features.
But they all have one thing in common; The business layer is less prone to changes compared to the UI
layer. Let’s explore that now.
9.3 UI Principle 3: Imagine your feature as a Command Line Tool
On paper, it might sound obvious to decouple business logic from views.
However, during regular day-to-day work, it can become blurry again where to put specific code. We
may take shortcuts because of time constraints, or it might not be clear which part of UI should consist
out of simple views. Conversely, it may get hard to figure out which views should know about business
logic and contain data bindings.
All of this could cause business logic mixed in with UI. You may end with UI components that are only
usable for one specific use-case.
When creating features, it can be a good idea to regularly check in with yourself and ask:
“What if this feature should also work as a Command Line Tool?”
NOTE: With the risk of over-explaining: A Command Line Tool is a program that runs on the command
line without UI.
Thinking of our app as a Command Line Tool does not mean that we are planning to run our program
on a server or without UI (a headless client). But, it helps us reason where to put logic, and defer UI
decisions if we so please.
It also does not mean you need to make Command Line Tools to deliver an app. Thinking of an app
or feature as a Command Line Tool is to ensure we keep business logic decoupled. It means that all
functionality and state is available, even without UI.
198
Mobile System Design
9.3.1 A feature on the command line
Let’s assume we are building UI functionality to toggle TODO items from completed to uncompleted;
Now it becomes very tempting to place that logic near the UI that you’d be building.
You may implement this toggle logic in a viewmodel or declarative view, because that’s where the UI
interaction occurs and where visually the state would change.
NOTE: In this view, we depict the “Toggle TODO item” action as an oval.
But, once you pretend the app should also work on the command line, you realize that this is a not the
best choice. Because the toggling logic would only be available in the UI layer.
For instance, below we pretend we have a Command Line Tool. We haven’t implemented it; we are
merely designing how to use it.
First, we fetch the courses using a user id (all ids are UUID ids). In our scenario, it returns one course
with its id. Then, we use that course-id to fetch its TODO items. Finally, we toggle one TODO item, and
its state becomes completed.
1 % course --fetch-subscribed-courses 1AAA5872-86C6-4501-948FCC7A05FA8025
2 ED8F92E2-983C-4BCD-864C-E77257238C62, @CalebGuitar
3
4 % course --fetch-todos-for-course ED8F92E2-983C-4BCD-864C-E77257238C62
5 5885206E-1836-45B3-9E5F-93A60176852B, Transcribe verse of Stairway to
heaven
6 ACB77219-DA08-4A1B-ACE9-E0F9BAE9B515, Find all C notes on the fretboard
7 63E2D8FE-0B1D-42FF-980B-706611948F58, Practice intro of smoke on the
water
8 B5EE3633-23B5-4E19-9271-78E21702C621, Practice the G major scale
9
10 % course --toggle-todo ACB77219-DA08-4A1B-ACE9-E0F9BAE9B515
11 completed "Find all C notes on the fretboard"
199
Mobile System Design
By pretending you only have a Command Line Tool, you don’t have to wonder whether specific business
logic should live in a viewmodel or declarative view, because by doing so the Command Line Tool
would miss a feature.
We could not achieve this if any of this code lives in a view, or viewmodel. We can only make this feature
work by making sure one of the business domains handles the state.
In this case, this little exercise tells us that the viewmodel’s toggle logic should live in the Course
domain instead.
A benefit of storing the state in the business domain – Course in this case – ensures it becomes a single
source of truth. Now it can serve any UI framework, architecture, and target, such as phones, tablets,
or watches. It means we make the model domain self-sustained, allowing it to be moved around (e.g.
to a different repo) or you can even open source it.
By supporting an imaginary Command Line Tool, we already set up our codebase for success.
NOTE: Although pretending to deliver a command line tool is an excellent exercise, it can be worth it to
make one for real; It can help to fire off a large amount of network calls to debug and (integration) test
your app. Then you don’t have to rely on a simulator or UI Tests to test the network-integration.
9.3.2 Flexibility by disconnecting the business logic
The Course example is small. But, the thought exercise helps when it’s not clear where to place
business logic.
200
Mobile System Design
By pretending we are also supporting a Command Line Tool, we completely disconnect the business
logic. By disconnecting the business logic from UI, we stay more flexible.
Whether our application runs on UIKit or SwiftUI is less important. Our application is self-sufficient and
it “just so happens” that we can use it for UIKit, SwiftUI, AppKit, Apple TV, Apple Watch, Apple Vision
Pro, widgets, App Clips, headless clients, or Command Line Tools, if we so wish.
It’s easier to support various UI paradigms.
If we want to implement background-support – such as having our app sync state while backgrounded
– it will, again, be much easier without UI dependencies.
That extensive list alone should show you that you’ll be more nimble that way for many future directions
in your company.
9.3.3 Fat business domain, lean UI domain
By thinking of our app as a Command Line Tool, we go one layer beyond the UI. We make sure the
entire feature works standalone without UI, as opposed to finishing 80% of the feature in the business
domain, and leaving 20% in the UI domain – such as the ability to complete a TODO item.
Now you may think this “making a Command Line Tool mindset” is premature optimization. But, in
most cases we get away with leaking some business logic into the UI domain, such as putting business
logic in viewmodels.
It’s still worth it to think of your app as its own system without UI. Because even if you’re a mobile
developer supporting only phones for all eternity, you may still experience significant changes and
201
Mobile System Design
migrations.
To give an example; Not too long ago, the iOS community went from single screen apps to multipleviewcontrollers per screen. They went from a single app to an ecosystem of an app with extensions,
widgets and App Clips. Even when only supporting phones, mobile engineers went from imperative to
declarative UI, such as migrating from UIKit to SwiftUI. Or from XML layout to Jetpack Compose for
Android engineers.
Some unfortunate souls even have to make their feature work with hybrid solutions.
Not to mention, some features may – at some point – need to work when the user backgrounds an app.
Which, again, is easier when there is no import UI statements sprinkled around core logic.
If we were to leave some business logic lingering in the UI layer, then any of these aforementioned
scenarios would have been more painful to support; We’d either have to duplicate our code, or complicate things to keep various data in sync. Or we would need to move code to the business domain,
anyway.
Reality changes even on a single platform, and we benefit from decoupling UI from business logic as
much as possible. Which is why thinking “What if this feature is also a Command Line Tool?” can help
you decide where to put the proper logic.
9.4 UI Principle 4: The UI does not dictate architectures in business
domains
It’s tempting to let the UI architecture define the underlying architecture.
You might have a reactive codebase and feel the need to make everything observable across the entire
app.
This is a nuanced problem. Because observable types are valid outside of the UI domain.
But, it can be a response to thinking UI-first, as opposed to designing your features as standalone,
without UI.
Let’s assume you’re a vendor that delivers an SDK and let’s assume your codebase is written in Swift.
Now, one customer wants to use your code in an Objective-C codebase.
Would you rewrite your entire codebase Objective-C to support this client? Probably not. A wiser
decision would be to offer an Objective-C layer around a pre-existing Swift codebase – assuming the
client already won’t do this.
Yet, when working with our UI architectures, we can fall into the trap of letting the UI define the core
architecture. Such as making our entire codebase reactive because that’s the architecture we use for
202
Mobile System Design
UI.
But, as we’ve seen before. By thinking of the business layer as a fat layer, and UI as a thin layer, we can
support more products or targets. This keeps us more nimble.
Think of the UI as a separate entity. Think of it as another customer or consumer of our domains.
You could ensure to support one paradigm first, such as reactive UI. Then, once the app grows, you can
offer more support to other paradigms, if needed.
But underwater, you would only have an architecture that’s best for that feature, not solely for reactive
UI.
9.4.1 Supporting a variety of products and architectures
Ensure you keep business logic out of the UI layer. You will end up with core functionality, with a thin
UI layer on top. The UI layer is like a shim or veneer to support the implementers, team-members,
customers, clients, or other UI paradigms.
With this line of thinking, you’ll be ready for any changes or platforms that come your way.
To extend our Course example from before, we have one core domain, containing all the Course
(model) logic. Then we can support several devices and platforms.
For instance, we may extend our app to a Watch app or TV app, using declarative UI. Then mobile devices,
such as phones and tablets, may implement Course using an imperative and reactive approach. We
still support a Command Line Tool that we may use to test network connections between the app and
servers.
203
Mobile System Design
For the short term, it might seem really important which architecture to use. You have to deliver, after
all.
But, looking at the graph, it can be healthier to see UI architectures as lightweight layers around core
logic.
Long term, the UI architecture you work in today wouldn’t need to play a large role in your application.
Your app might be a variety of various UI architectures.
Over time, it’s not too hard to imagine that we can support even more architectures or platforms in the
future, such as VR frameworks.
This, again, is possible by ensuring the proper logic stays out of the UI layer.
204
Mobile System Design
9.5 What we covered
In this chapter, we covered:
• By implementing UI at a later stage, we get various benefits. Such as focusing on keeping business
logic decoupled from UI at the start, and getting a better idea of what we’re building.
• UI Architectures are prone to fads. If you focus on keeping the UI domain lightweight, you’ll be
better prepared for the next architecture.
• Most UI Architectures are good at the problem they solve. Instead, see them as an alignment
tool within teams to keep code predictable and easier to maintain.
• By pretending you are delivering a Command Line Tool, you can reason better at keeping business
logic out of the UI domain.
• Enforcing that business logic doesn’t leak into the UI domain, means it’s easier to support new
products, architectures, or UI paradigms.
• Think of your app as a fat business model with a thin UI layer on top. So you will stay more nimble
when you swap to a new UI architecture, framework, or paradigm.
• Avoid letting a UI architecture dictate the architecture of business domains. Because your app
may support more than one UI architecture at some point.
205
10 Delivering reusable UI views; The art of
decomposing a design
In this chapter
• How to decompose a screen into views for both a feature and a potential UI library
• Reasoning about naming and abstractions for UI views
• Making primitive UI types that can live in a UI library
• Breaking down views into reusable parts
• Making reusable views while avoiding over-engineering
We’ve covered the benefits of implementing UI later in the development process. Now, it’s finally time
to focus on delivering UI.
In this chapter, we will focus on decomposing the Course screen – which represents the screen from our
original briefing – into smaller, separate, views. Then we can use these views to build up a CourseView
type, which is the technical representation of this screen.
This chapter assumes you already do this type of work regularly. However, we are going to dive a bit
deeper.
While decomposing the screen, we are going to be extremely nitpicky about naming and the abstractions of views. Because in those nitpicky details is where we can reason about views more deeply.
Naming is especially important, because having to think of a proper name for views forces us to think
about its role, where it fits in the abstraction-hierarchy, how reusable it should be, and how to avoid
over-engineering.
The goal of this chapter, is that you’ll learn not only to deliver a screen. But to deliver screen plus a
healthy UI library.
At the end of this chapter, you’ll have a better grasp of views, naming, and their abstraction. Then, in
the following two chapters we’re going to take these lessons further. This is where we’ll connect, or
bind, these views to data models, to deliver a fully functional CourseView.
We’ll continue with the UI principles started in the previous chapter. These principles are more timeless
and work regardless of the UI architecture or framework that you’re using.
207
Mobile System Design
10.1 UI Principle 5: Name a view after what it is, not how you use it
This next principle is not specifically UI related, because it can apply to any (model) component we
make. However, it comes up often in our line of work so often when creating UI, so we’ll cover it here.
Most views don’t have to be aware of a feature.
This might sound very paradoxical. After all, as mobile engineers, we constantly make UI to deliver a
feature.
Views support a feature, but often, they do not need to know about it. Understanding this subtlety is
the difference between reusable views and a less agile UI codebase.
One very common “mistake” is that developers give a type (such as a view) a name that’s too specific.
As if the view should know it’s part of a feature.
By doing so, you’ll end up with a less reusable library of UI view, and an increase of similar, yet slightly
different views.
Sometimes this mindset results in views becoming too aware of how they are used; In essence, they
become “smart” views, optimized for a limited amount of use-cases, making the views more niche, or
complex, as a result.
10.1.1 Making a reusable view
Let’s do a little exercise by looking at the tutor profile from the Course screen to get a deeper understanding of creating views. We start here because it’s the simplest view we have to make.
After that, we’re going to extrapolate and apply this method to some other, more sophisticated, views.
If you were to make the following view, what would you call this?
If you answered something like TutorView or MentorView, then unfortunately it’s not a reusable
view and any dreams you may have of delivering UI for other teams are squashed.
Jokes aside, most developers would call this view something like that, too. In this case, we are looking
at a tutor, so it’s only natural to think “Let’s call this a TutorView!”.
208
Mobile System Design
Naming this view TutorView works on a local scope for just our use-case, but the type name is
unnecessarily specific. Because the type name implies that this view should only be used for tutors.
Yet, the view itself is nothing but an image and two labels.
Let’s imagine we’re implementing a user profile for students, using the same view; We could theoretically
re-use TutorView for a profile. They are the same UI after all. But the name TutorView wouldn’t
make sense since we would use it for any user’s profile, not just tutors.
So we have a few options:
1. Misuse the TutorView for a user and tell ourselves (and our coworkers) “Yeah I know the name
is odd, but it’s the view we should use”.
2. Rename the view to something more generic, such as ProfileView.
3. Duplicate this view, so you end up with both a TutorView and another type such as
ProfileView.
4. Subclass the view with overrides, name the superclass ProfileView (it’s more generic) and
the subclass TutorView (since it’s more specific).
5. Extend the view, by making it configurable to support both users and tutors.
6. Disconnect the view’s name from its use.
Most of these options are sub-optimal.
For instance, with option 1, we are misusing a view. Renaming it to something more generic, such as
ProfileView (option 2) is a good step in the right direction, but the view’s name would still leak how
it’s used.
With option 3 and 4, we end up with another view for the same thing, which would make things overly
complicated.
With option 5, we are complicating this view, which is avoidable, because a rename should be enough.
Option 6 would be the preferred option, as we can make the same view with a different name, to better
match its abstraction. Let’s explore how.
209
Mobile System Design
10.1.2 Type name versus instance name
The problem here is that what a view is gets conflated with how we use a view.
To translate this to programming terms: We are conflating the type name with the instance name.
We could state that the type TutorView is too specific. ProfileView as a name for the type is a step
in the right direction, but it’s still too specific because that name still leaks out how it’s used – namely,
for a profile.
Ideally, we can come up with a name that doesn’t leak how it’s used. Then it becomes usable for any
situation.
We would need a name that shows what something is. It’s not easy to find one, and we will end up with
a more generic name. Let’s try ThumbDescriptionView, since it’s a thumbnail with two lines for a
description, nothing fancy.
When we rename the view to ThumbDescriptionView, it doesn’t leak how it’s used. The downside is
that its use-case isn’t instantly clear, unlike ProfileView.
However, since the view’s name is now completely disconnected from its use, it opens up a large number
of contexts in which we can implement the view, both in current and new, unforeseen, scenarios.
Note that we’re only changing its name, not its functionality.
NOTE: Even though ThumbDescriptionView is less specific, or more generic, it does not imply that the
type should use generics. We are merely making the name more generic.
210
Mobile System Design
Another way to look at this is the more specific we make a view, the more likely it’s used for a specific
feature only. The more generic we make a view, we more likely it lives in a UI library, used by many
features.
211
Mobile System Design
10.1.3 Expressing reusable view in code
In code, we would express a reusable view where we name the instance tutorView or profileView,
but we name the type ThumbDescriptionView. This means that we can still use the terms we initially
wanted to use, such as tutorView or profileView, but only for instance names, not the type.
1 // Suboptimal: The TutorView type leaks how it's used (can only be used
for tutors).
2 let tutorView = TutorView(displayName: "Caleb Davis", handle: "
@CalebGuitar")
3
4 // ProfileView is a more generic name.
5 // This is better, but still not ideal.
6 // Because the view still leaks how it's used (can only be used for
profiles).
7 let profileView = ProfileView(displayName: "Caleb Davis", handle: "
@CalebGuitar")
8
9 // Better. The view can now be used for any scenario.
10 // The instance names still tell us what the type's purpose is.
11 let tutorView = ThumbDescriptionView(title: "Caleb Davis", description:
"@CalebGuitar")
12 let profileView = ThumbDescriptionView(title: "Caleb Davis",
description: "@CalebGuitar")
Notice the parameters we pass. We pass a displayName and handle for TutorView and
ProfileView, which is more specific. But, for ThumbDescriptionView, we pass the more generic
title, and description. The view is still the same, we just give everything a more generic name.
It’s important to note that we aren’t changing the view, we aren’t "making it more reusable just in
case”. We aren’t adapting it for "possible future scenarios". We are merely giving it a name that is more
disconnected from its use.
10.1.4 View primitives
We are using this view for tutors or a profile or a course. However, the type itself doesn’t “know” or
“care” how it’s used. It’s merely a thumbnail with a description.
So we call it something more generic, such as ThumbDescriptionView. Which is closer to what this
type is. It’s a “dumb” view that shouldn’t know anything about the feature that uses it.
But instead of calling it a dumb view, let’s call it a view primitive. Just like how we use primitives like
String or Array to build more complex data models, so can we use a view primitive to build complex
views.
Thanks to a generic name, we unlock this view for other features, too.
212
Mobile System Design
Now, this view could even live in a UI library if we so desire. In fact, let’s do just that. Then it can help
others or ourselves in the future.
Inside our project, we can move this view to a separate UI Library folder.
To give it more context, we’ll define a CourseView, living in the Course UI domain, which represents
the entire course screen. This CourseView depends on the ThumbDescriptionView (and other
views to come).
10.1.5 The road of becoming too specific
Alternatively, we could double-down on a specific view. If we kept the name TutorView or
ProfileView, it might have properties such as displayName and handle, which we could fill with
string values such as “Caleb Davis” and “@calebguitar”.
Then, if we want to use this view in a different scenario, such as a profile view for a user (not a tutor),
we might expand on it to support more scenarios.
We could then make our view more elaborate and ergonomic, such as adding firstName and
lastName, or maybe add a little field to display data such as “registered on May 14th 2023”.
Then we need to have multiple variants of this view, one with the field for the registration date, and
one without it.
But the independence of the view is damaged. It starts to "know" about how it’s used, creating implicit
tight-coupling between this type and its owner (parent). Worst case, it opens the door to explicit
tight-coupling, such as an engineer directly pass the Tutor data model to have the view configure
itself.
But because we keep this view ThumbDescriptionView, its labels would be something called
213
Mobile System Design
more generic, such as titleLabel and descriptionLabel, which isn’t as fitting as firstName or
lastName. But, it allows for numerous use-cases, instead of just one.
Even better, it keeps the decoupling explicit. Because now, if an engineer ever wants to pass a Tutor
to this view in the UI library, it would look really suspicious, whereas before it wouldn’t be too strange
to connect a Tutor to a TutorView when they live right next to each other.
Again, these are small examples for this tiny view, but in practice, people start expanding views to
suit their use-case, making types overly specific, and thus slowly creating tight-coupling over time,
harming reusability.
10.2 Introducing abstractions without introducing new types
It’s understandable that you may want to create a view called TutorView because then the type
matches our use-case nicely. We may even have custom properties that better match the use-case. We
might call them something like displayName and handle. This would make the view more ergonomic
to use for our feature.
By defining a TutorView, you are, in essence, working at a higher abstraction.
But, even without introducing a TutorView view, we can get a “best of both worlds” scenario using
factories or custom initializers. This way, we keep a reusable view, but we get the more specific
ergonomics that a TutorView might offer.
For instance, in our feature, we may offer a factory method called makeTutorView, where we can
return a ThumbDescriptionView. This method would then have the displayName and handle
parameters that would fit a tutor.
In this code example, we create a tutorView instance of type ThumbDescriptionView, but we
still use tutor-specific names in the factory. Namely displayName and handle properties (used as
parameters).
1 // Our tutorView is of type ThumbDescriptionView, but we can initialize
it with a displayName and a handle.
2 let tutorView: ThumbDescriptionView = CourseUI.makeTutorView(
displayName: Caleb Guitar", handle: "@CalebGuitar")
We are putting this method in a CourseUI namespace, to indicate it lives on in the Course feature.
To express this in Swift, we can make a static function on the CourseUI enum, which is an informal
way to express a namespace in Swift.
Notice that we accept the displayName and handle, but we use that to fill the more generic
titleLabel and descriptionLabel from ThumbDescriptionView.
214
Mobile System Design
1 enum CourseUI {
2
3
// We pass tutor-domain specific names: displayName, and handle
4
static func makeTutorView(displayName: String, handle: String) ->
ThumbDescriptionView {
5
// Underwater, we still initialize ThumbDescriptionView with
the generic titleLabel and descriptionLabel parameters.
6
return ThumbDescriptionView(titleLabel: displayName,
descriptionLabel: handle)
7
}
8
9 }
In essence, we are creating an abstraction local to the Course feature, without having to introduce a
new type.
10.2.1 Abstractions via aliasing
Alternatively, we could alias our type to express a higher abstraction without introducing a new specific
type.
If your programming language allows for this, you can use a different name for the same type without
introducing a new type.
In Swift, we can make an alias with the typealias keyword.
ThumbDescriptionView as TutorView.
This allows us to refer to
1 typealias TutorView = ThumbDescriptionView
This way, we could let ThumbDescriptionView live in a UI Library, but in our use case, we can keep
referring to TutorView for expressivity in our Course feature.
We can have this return from our factory, too. Note that we now return TutorView from our factory
method.
1 enum CourseUI {
2
3
// We now return a TutorView type.
4
static func makeTutorView(displayName: String, handle: String) ->
TutorView {
5
return TutorView(titleLabel: displayName, descriptionLabel:
handle)
6
}
7
8 }
215
Mobile System Design
We can take this factory one step further, and instead create an extension to add a new initializer to
TutorView (which secretly is the ThumbDescriptionView type).
This initializer is similar to the factory, except it now lives on the TutorView itself.
1 // We're extending TutorView, alternatively we could extend
ThumbDescriptionView since they're the same.
2 extension TutorView {
3
4
init(displayName: String, handle: String) -> TutorView {
5
// We initialize the TutorView or ThumbDescriptionView with its
more generic properties.
6
self.init(titleLabel: displayName, descriptionLabel: handle)
7
}
8
9 }
We can delete the CourseUI namespace, and now we can initialize a TutorView with a regular
initializer.
1 let tutorView: TutorView = TutorView(displayName: "Caleb Guitar",
handle: "@calebguitar")
2
3 // To clarify, this type is still a ThumbDescriptionView
4 let view: ThumbDescriptionView = TutorView(displayName: "Caleb Guitar",
handle: "@calebguitar")
We get a lot of benefits; We keep the reusable ThumbDescriptionView type that could fit many
use-cases and even live in a UI library. But, in our feature we can keep working with a TutorView
which keeps it more ergonomic.
The downside is that TutorView isn’t completely aware it’s used for a tutor. It’s still a
ThumbDescriptionView underneath. So that means when someone wants to read the handle, e.g. tutorView.handle, they’d have to use the descriptionLabel property instead.
NOTE: We can keep going and make more extensions to TutorView for ergonomics, such as a handle
property, but then we’re probably overcomplicating it.
10.3 Creating the other view primitives
We took a lot of time covering the tiniest view, and I won’t blame you if you think we were being
pedantic for one thumbnail and two labels.
Having said that, being detailed – even if it’s for a tiny view – touches an important point, this mindset
that we applied to ThumbDescriptionView makes or breaks a healthy UI library over time.
216
Mobile System Design
By continuously keeping in mind how to divide UI and in what abstraction they live, we end up with a
healthy UI library of reusable views.
We’ll take the same mindset we used to create the tiny ThumbDescriptionView and apply it to the
rest of the screen. The goal is to end up with more views that are more reusable and better prepared
for a flexible future.
Keep in mind that it takes the same effort as making these views with an overly specific name.
Let’s pull up the screen again to see what views remain and see what’s left to create.
NOTE: We’ve decided earlier in the book not to support multiple courses, so we’ll skip that view.
217
Mobile System Design
10.4 UI Principle 6: Don’t name a view after its styling
One view to implement is some sort of speech bubble or "shoutout" view right underneath the tutor’s
avatar, giving us a message.
Using the methodology from before, we won’t call it something like TutorMessage because that
implies only tutors can message us, this view doesn’t care about that. Nor will we call it out Shoutout
either, because that implies that this view can only be used for shoutouts, but what if it’s a different
kind of message? Or a reminder? Not all messages are positive reinforcements. Again, how it’s used is
not something this view "cares" about.
Let’s get philosophical again: Instead of thinking "How are we using the view?" we will ask ourselves
"What is the view?"
NOTE: We always ask "What does this class do?" but never "How is this class doing?"
If you search online for "callout", you get a text balloon with an arrow pointing to a location, that seems
to be fitting. This callout could be a positive reinforcement, or it could be used for other things, such as
highlighting new functionality.
Since a callout is more generic, and says less about how it’s used, we can settle for CalloutView.
NOTE: If you remember, we also modeled this as the callout property on Tutor in the Holistic-Driven
Development chapter.
Finding a suitable name will give us yet another reusable view that doesn’t know anything about tutors
or courses. Again, we aren’t making it reusable on purpose, we are only giving it a fitting name that’s
not overly specific.
We’ll need to define a property for the message inside the CalloutView view, let’s call this message.
NOTE: In this chapter, we’ll omit the implementation so that we can focus on the concepts behind views.
We’ll model all views in the Implementing UI chapter.
Then the next question is; Should the dismiss button (on the top right corner) be part of CalloutView
or should its owner (the screen perhaps) place the button on top of this view?
This is a subtle decision to make, and either way is fine. It won’t cause big delays if we do it “wrong”.
218
Mobile System Design
You can choose to make the button its own tiny view, and then add it to the CalloutView view for
portability. So that if someone else wants to use this view, they’ll get the close button for free, and if
they don’t like it they can hide it. Since the close button is its own view, others can grab it for other
purposes.
In our case, we won’t offer a separate CloseButton to keep it simple.
Finally, the arrow is now fixed to the top-left. We could go out of our way and make the arrow support
more corners, or make this view support no arrows at all. But I recommend making a view configurable
only when needed.
Because once this screen goes live, the team may decide to delete the callout feature since we don’t
know whether tutors will even use it in the first place. So let’s not waste our time making this view
more reusable than it needs to be. Here, we can make the arrow fixed to one location.
Let’s update our UI landscape and add the CalloutView. We can make it part of a UI Library since
it’s not specific to the Course feature.
10.4.1 Alternative names
Just because we decided on a name doesn’t mean there aren’t (better) alternatives. For instance,
SpeechBubble would also be acceptable, since the view displays a bubble with a message and an
arrow.
However, in the data layer, the property speechBubble would be a worse alternative than the callout
property on Tutor. Because Tutor is a data model, and from that perspective, it doesn’t matter how
the callout is displayed, speech bubble, tooltip, or otherwise.
Try to disconnect the UI styling from the name.
219
Mobile System Design
For instance, SpeechBubble tells developers they are getting a visual speech bubble. But a CallOut
can have any type of styling, a speech bubble, or something else.
In essence, CallOut is a bit more generic and decouples the styling from what it is, making it a bit
more futureproof. For instance, let’s say that in the future, a designer replaces the speech bubble
styling with an exclamation mark icon plus a label. We can then restyle CalloutView under the hood,
the API will remain the same, and only the UI team will need to perform the work of restyling.
Whereas if you went with the name SpeechBubble on the other hand, it would false give the impression
that it gives a bubble. The name would be out of sync from its use. Now it will have to be refactored, or
be deprecated to make room for a new view. As a result, all teams using SpeechBubble will have to
update their feature to use the new view.
This scenario is not a big deal if it’s a tiny view used by only one feature, but it does become problematic
on a larger scale.
10.5 UI Principle 7: Favor composition over smart views
The next view is used to inspect and schedule 1on1 calls with the tutor. Let’s pull up the view.
This view is slightly more intricate. We have various labels and at least two buttons, namely Join call
and Reschedule.
There might be more variants, such as a state when there is no schedule set.
Let’s keep it as simple as possible for now and implement what we received. We can always make it
more complex later if needed (but preferably not too much).
We were able to make the previous views more reusable by merely using a better name. But unlike
those views, it doesn’t make sense to offer a generic reusable ScheduleView view to a UI library; It’s
too specific to our feature.
Because, even if we came up with a very generic name, it’s highly unlikely someone else would need
this view.
Since we don’t need a reusable view, we can keep this view’s name more specific, such as
ScheduleView.
That also means a ScheduleView will live outside of the UI Library.
220
Mobile System Design
However, what we can do is break it apart and perhaps some bits and pieces (subviews) can be more
generic and could even live in a shared UI library.
We are not making this a reusable view, but we are breaking it down into subviews where some of
those are reusable.
We could grab the “Join call” button and name it something like DetailButton, but again that leaks
how it’s used (for details). The button itself doesn’t need to know how it’s used. What we can leak
in the name is that it has some sort of accessory, since it’s not specific to a use-case. Let’s call it
AccessoryButton instead to resemble closer what it is.
We also know we need a small button for “reschedule”. It’s a small button, but we don’t want to leak
that this is a small button and call it SmallButton. Because that assumes this button is always small,
but when the device is set to a large font and if the label is long, it’s not so small anymore. Instead, let’s
call it TextButton to indicate that it’s a text button element, making its name a bit more future-proof,
so it serves texts of all sizes.
Then the scheduler part we can name something quite specific, since it’s only useful for our feature.
Let’s call it ScheduleView.
Let’s update the landscape. Notice how we’ll place ScheduleView in the Course UI domain, since
it’s more specific. We’ll place TextButton and AccessoryButton in the UI Library domain, since
they are more generic.
The ScheduleView now uses two reusable views (buttons), namely AccessoryButton and
TextButton, on top of a couple other views (such as labels and an icon) which can be used by
ScheduleView.
221
Mobile System Design
The buttons can live in a UI library. But since the ScheduleView is only used by our feature, it will live
there.
10.5.1 Decomposing the TODO list
Now we’re going to tackle the TODOList view. Let’s pull it up:
We have been calling this view TODOList because from our perspective that’s the feature we’re making.
But again, technically it’s not a todo list. We are using it as a todo list, but that’s not what this list is.
Again, the pitfall as developers is to see a design of a TODO list and think "I am going to create a
TODOListView!".
It’s sometimes too easy to think locally and introduce UI specific to your feature. This allows you to
deliver quickly, but in a larger setting, you risk reinventing wheels.
This list is some kind of list where the user can make selections. That selection is now used by this
feature for TODO’s, but this view could theoretically be used for other things; One example could be
security settings to select whether you want to authenticate via Text Message and/or a Security key. Or
whether you want to gift your tutor catfood or dogfood as a token of appreciation.
We have to think of this view in a more generic way. We could think in terms of “options” or “selections”.
To make the name better match what this view is, let’s think in terms of “selections” and name this
SelectionView. Allowing a user to select one or more items.
222
Mobile System Design
10.5.2 Domain naming versus view naming
You may wonder, if the TODOList is a SelectionView, then why don’t we name the TODOList
domain something like the Selection domain?
Let’s exercise that thought and discover why it wouldn’t fit well.
We could deliver a Selection domain, with views that don’t know anything about TODO lists. Then we
could deliver nice, reusable views for any developer that wants a variety of selection mechanisms.
However, in our case, the TODOList domain does more than make selections. It’s an abstraction
above selection. It may not know about courses, but it does know about API calls and resetting TODOs,
weekly TODOs, scheduled TODOs, and so forth.
Because of this, TODOList sits on a different plane of abstraction, above Selection types. The
TODOList domain can use Selection primitives to achieve its goal, like a SelectionView, which we
do now. Theoretically, it could use other selection primitives, such as a hypothetical SelectionModel
or SelectionFlow (not defined).
10.5.3 Handling inconsistent UI
The UI of this view consists out of sections which consists out of rows. Then there are two titles, and a
“reset all” button.
We’ll have to decide what to do with the titles, since they are inconsistent. There is one large title – see
“This week’s schedule” – and the section title below is smaller – see “Every day”. This tells us that the
first section title is larger, or that the first section is missing to make room for a separate title label.
We can make all of this title logic configurable for one “smart” view. But thinking in this way will make
our view quite opinionated, because it will dictate quite some intricate logic. Such as “This view needs
a title, so that means that the initial section has no label, and other section labels are smaller”. Or
“When there is a title, the first section has to be hidden”.
But we would be on the path of an opinionated view with high configurability. Creating an opinionated
view can be problematic and results in a few issues:
1. The view is by default less reusable because maybe others don’t always want a title or a reset
button.
2. The view does more heavy lifting (it can set a title hide section titles). But, by doing so becomes
more intricate, which means a higher chance of bugs and making it more complex to maintain.
3. We risk others using ugly workarounds to deal with the opinionated view. For instance, another
person may want this view without a title, and they might set an empty title and change its
child’s view dimensions to compensate.
223
Mobile System Design
4. We risk making the view more complex. Maybe we’ll make the title optional, but now we’ll be
adding variations and configurations to this view, making it more “smart”. But “smart” also
means complex.
Instead, we should favor composition to create complexity. We’ll make “dumb” views – or view primitives – that together make up this more complex view. That’s easier to work with, as opposed to
delivering a “smart” view that can do it all.
In our case, that means we’ll break this view down into smaller views.
10.5.4 Breaking a view down into smaller views
How about we take one step back and ask ourselves: Do we even want sections in this view?
It’s highly likely that our view will only ever have two sections, maybe even just one. Coming up with a
smart view able to handle any amount of sections sounds like overkill and over-engineering.
We’ll keep it simple and make a SelectionView that only contains rows which we’ll call
SelectionItemView.
The SelectionView view will then look as follows:
The SelectionItemView represents a row and contains a toggle button, label, and an arrow. We’ll
call it SelectionItemView.
There will be zero or more SelectionItemView elements in the SelectionView.
SelectionView can display a list of items and handle the toggling.
The
Since we simplified SelectionView, it does not know about a title and the reset button on top. We’ll
add that to the screen it lives in.
The same goes for the "Every day" section label, which can be its own label on the screen.
Now we can offer these views in a UI library.
224
Mobile System Design
10.5.5 Avoid delivering for hypothetical scenarios
In our case, the SelectionView component allows for a multi-selection. That’s what we’ll offer first
and others can use that too. We can offer this view to a UI library, and if others want to add features –
such as a single selection mechanic such as radio buttons – then they can add that with little effort if
needed. But we don’t have to offer this functionality, since we’re the sole user of this view and it’s not a
requirement for anyone else.
Now, the titles and “reset all” button are not part of the view, which means we’ll add it to the screen
as is. But that’s not necessarily a bad thing. The downside is that if another person wants the exact
same layout with a title and a “reset all” button, they’d have to duplicate our solution or extract it to a
new view. But that’s a small risk we should take, since the upsides outweigh a hypothetical “what if
someone sometimes wants to do x” scenario.
It’s important to remember that we aren’t developing for hypothetical scenarios. That’s overengineering.
10.5.6 Button views
We can reuse TextButton to create the "reset all" button. Now there is one last view remaining; In
the top right of our screen we have a generic button view that’s used to message a tutor.
225
Mobile System Design
We have to come up with yet another name for a button. We can call it after its looks, like a
BorderButton, but that will leak its appearance. Which is a problem because maybe its border
might be removed in a future update. Let’s think in terms of functionality; it’s sort of a less-important,
subdued button.
Let’s call it SubduedButton. This captures its semantics better, it tells others that this is a view for
a button that’s less prominent. No matter whether it has a border, or whether we update it to an
underlined button, it will always function as a subdued button.
NOTE: Alternatively, TertiaryButton could also work.
226
Mobile System Design
10.6 Conclusion
Because of the way we’ve been defining our views, most views we created are not aware of courses, we
created them as generic, “dumb”, views, which we call view primitives.
But calling these views “generic” or “dumb” is not a bad thing. On the contrary, it means these views
are more simple and reusable – which is beneficial to the project.
An important part is that we did not make these views reusable just in case. The views are only made
more reusable in the way we named and decomposed them.
Since we delivered reusable views, we put them in a UI Library domain for anyone to use.
In this chapter, we have been focusing on the UI domains. In the upcoming chapter we are going to
build on this and reason about connecting the UI to our feature.
Regarding the files, we can place the UI Library views in their respective folder, or even in a single
file. The ScheduleView can live near the course feature or folder. Once time progresses, we can move
the UI Library file or folder to its own module, if we so please.
227
Mobile System Design
10.7 What we covered
In this chapter, we covered:
• Decouple the styling from a UI view to make it more future-proof.
• To give a view a better name, think about what a view is, as opposed to how it’s used.
• Use a view instance name to reflect how you use it.
• By making a view name overly specific, it becomes less reusable.
• Prefer making view primitives over making "smart" views that are highly configurable.
• Prefer making more complex views from smaller, simpler views.
• By making a view useful for hypothetical scenarios, we can fall into the trap of over-engineering.
228
11 Reasoning about Views, Components, Screens,
and Bindings
In this chapter
• Understanding the differences between screens, views, components and features
• Various approaches on how to connect views with business logic
• Understand when and why you would want to use UI patterns, such as viewmodels
• How to keep imperative code more lean
• The role of screens and how they are related to views
• Where complexities should live inside an app
• Making components support multiple contexts
• How to decide if a component should be reusable
It’s a question that ancient philosophers have tried to answer for ages: “What came first, the
component or the view?”
In the previous chapter, we have created views with which we can build our feature – or screen, namely
CourseView. For most views that we introduced, we kept them “dumb”, also called view primitives,
meaning that they do not contain much data or logic.
We also took extra care to give them fitting names. Because of this, they are highly reusable, unaware
of the feature they’re used in, and they can now live in a UI library.
Combined with the business logic, we now have the building blocks to build the CourseView screen.
In contrast, when we talk about a customer-facing feature, now UI suddenly does become part of
business logic. To make this work, we need to connect data and logic – such as a Course or Tutor
from the business layer – to specific views. This means that there needs to be a piece of code that
knows about everything, both the views as well as the business logic.
We are now entering the world of bindings. The act of connecting views to data and user-input, so that
we have fully functioning UI.
No matter which architecture or paradigm you prefer— such as using controllers in a MVC pattern, or
state handling in a declarative framework– in a nutshell it’s about receiving user-input, responding
229
Mobile System Design
to data changes, and making sure views show the proper, latest, data. If possible, we want to keep a
smooth frame rate while we’re at it.
That means we have to think about where logic and bindings go. This invokes a lot of discussions
about "best practices" and UI patterns, which we’ll explore in this chapter.
First, to get a better understanding of a view’s responsibilities and how complex they should be, we
are going to put them into three categories.
Then we will zoom out and see how all views connect to make up a feature, or screen. This chapter will
cover various UI patterns, such as viewmodels and whether we need them, for both declarative and
imperative solutions.
Then we will get philosophical and cover the role of “screens”. Because by understanding the terminology on a deeper level, it will be easier to build complex features, or screens, without racking your
brain.
Last, we zoom in again, and cover how to implement smart views, or view components. Because they
contain more logic and bindings, yet they have to be unaware of business logic, so that they can live in
a UI library. This requires a slightly different approach.
When delivering a smarter view, we have to wonder how reusable they should be. It’s a difficult
balance, because we risk over-engineering if we’re not careful. In this chapter we will cover some of
the nuances to help you to answer the question "Should this view be reusable, or should it be specific to
my feature?".
At the end of this chapter, you’ll grasp where to place complexity, the role of various views, and how to
glue them all together.
This chapter has plenty of diagrams but no code. The next chapter will be the opposite, because that’s
where we’ll write the actual implementation using the approach we define here.
Let’s continue by building on the UI principles from before. The benefit of using these principles is that
they apply to most frameworks and paradigms.
11.1 UI Principle 8: View components contain logic and/or bindings
In the previous chapter, we delivered “dumb” views that we classified as view primitives.
By doing so, we ended up with views that we could place in a UI library. This way our feature could use
them, as well as potentially others.
230
Mobile System Design
In this book, we describe view primitives as basic building blocks used to make more complex views.
Other examples of view primitives include things like buttons, labels, and switches.
In this chapter, we’re elevating our discussion one level beyond view primitives and refer to certain
views as view components. These view components have an understanding of data bindings and/or
encapsulate logic, which is why we label them as “smart.”
NOTE: The term ‘component’ is fluid and differs per platform. In some platforms, view components are
tiny views. At other places and in this book, view components are defined as having data and logic.
In fact, we secretly already created a view component in the previous chapter: The SelectionView is
not just a simple view primitive.
SelectionView is a view that has to keep track of a list of elements, and it tracks which elements a
user is toggling. It also needs to propagate when a user taps on the accessory button.
This view requires state, logic, and data to operate effectively, making it more sophisticated than a
simple button or label. This is why we classify it as a view component instead.
11.1.1 Distinguishing view primitives from view components
Let’s expand on this nuance on the diagram by categorizing views either as view primitives or view
components.
231
Mobile System Design
Notice how the SelectionView sits one abstraction layer above the view primitives. View components
can make use of view primitives, but not vice versa.
Another example of a view component could include things like a navigation bar, color pickers, custom
map views, date schedulers, date pickers, segmented controls, keyboards, steppers, sliders, input
fields, text fields, or a notification display.
The OS offers components, and often we need to supplement them by creating our own.
View components have more logic and functionality. Yet, just like view primitives, they are unaware of
the feature they’re used in. Because of this, in theory they could be used for hundreds of features.
Because components are unaware of customer-facing features, they can also live in a UI library of
reusable views.
We sort these library views into two layers of abstraction for clarity. This helps us comprehend their
purpose, data connections, and required logic. It tells us where they should be located, such as within
a particular feature or a UI library.
Note that we classify these mentally to reason about them better. We aren’t actually putting these
views in separate folders – although you can – because we currently only have a handful of views to
deal with.
Yet, there’s a reason for this classification; When dealing with a multitude of components, features,
screens, and a design system, it can be tough to answer questions such as, “Where does this view
belong?” or “Is this view part of my feature?” or “How does this view link to my data model?”.
When we’re not clear about the roles of views, it can lead to strong connections between views and
specific features. This not only makes building a UI library challenging but also complicates the task
of moving code to different parts of our workspace, such as separate features, or even into separate
modules.
232
Mobile System Design
11.2 UI Principle 9: Features can have local components
Let’s look at another view; We’ve introduced ScheduleView, a small view that allows a student to see
the upcoming time and date for their 1on1 call with their tutor.
We consider this a view primitive because it contains little to no logic. It requires two callbacks for the
two buttons and some labels.
In contrast to the other view primitives, we decided to store the ScheduleView in a folder within
the Course UI domain itself. This is because the ScheduleView is closely tied to the specifics of the
Course domain.
This is because it’s improbable that other features will require this view. On the contrary, it is probable
that other features will utilize the view primitives we’ve placed in the UI library.
But it’s a prediction.
To maintain flexibility, we ensure that ScheduleView remains unaware of the specific Course feature
it’s employed in, just like our other defined views.
Suppose we were mistaken, and this view is needed for a different screen in the future. In that case, it
would be straightforward to relocate it to a UI library because we’ve designed it to be an autonomous
view, completely detached from any knowledge about courses.
As a consequence, we position it within the Course UI domain or folder, yet it retains its independence.
233
Mobile System Design
11.3 UI Principle 10: Feature views are connected to business logic
On top of view primitives and view components, we have views that support features with business
logic. For this reason, we classify feature-specific views as feature views.
For instance, consider CourseView, which, in our app, is a declarative view positioned at a higher
level of abstraction compared to the other views. This view represents the Course screen in our original
briefing.
234
Mobile System Design
11.3.1 Connecting views to data
A customer-facing feature like CourseView is aware not only of the views it employs, but also of the
associated business logic, whether it’s implicit or explicit.
For this reason we classify it a feature view, a place where UI and business logic are connected.
NOTE: You may wonder why we call CourseView a "feature view" instead of a "screen". We’ll cover this
in the next section. But, the short answer is that a screen can be many things, such as a feature view, view
primitive, or view component, they don’t always know about business logic. Secondary, a "feature
view" does not always use up the entire screen.
Let’s show a fuller context, but time we’ll show the UI layer plus the business layer.
NOTE: Since Course itself also doesn’t directly depend on the networking classes, we omit it to keep the
diagram smaller.
Notice how, in this example, the CourseView feature is aware of the Course model. This means the
CourseView is a view that’s more specific than any other view we have introduced so far. We don’t
consider CourseView reusable, since we only can use it within the Course domain.
We haven’t yet figured out how CourseView will get Course from CourseAPI, but we’ll get to that .
First, it’s important to know that the CourseView works by connecting, or binding, the Course model
to its view and related subviews.
In other words, CourseView needs an instance of the Course model to function.
There are other ways to connect views with business logic. Let’s go over some alternatives.
235
Mobile System Design
11.3.2 Bindings can come in many forms
There are countless ways to link UI to business logic, such as through viewmodels, controllers, declarative bindings, state handlers, closures, publishers, coordinators, and more.
By choosing a way to connect UI to data, we open the floodgates of UI frameworks, architectures, and
squabbling about which pattern is the “best”.
In my experience, having participated in many discussions with mobile developers, it’s safe to say that
a substantial part of UI architecture discussions typically centers around the concept of “bindings.”
They all have their own rules, best practices, and pros and cons.
At times, as mobile developers, we tend to keep ourselves occupied by transitioning from one UI
architecture to another, even when the current one is perfectly adequate.
Before we dive into our implementation, let’s explore a widely used alternative because it will enhance
our understanding of UI patterns in general.
A common approach is to implement a viewmodel to disconnect the view from the business layer.
If we were to design with a viewmodel, we would introduce a new CourseViewModel that
CourseView depends on. Then, the CourseViewModel can directly connect to Course and
potentially CourseAPI.
The idea would be that we can extract logic out of CourseView into CourseViewModel. This would
make CourseView more reusable, and CourseViewModel would be unit-testable.
236
Mobile System Design
Looking at the graph, we see that CourseView still has a path to the Course model, there is just
one extra hop in between – namely, via the CourseViewModel. Whereas the other views have no
connection to the Course model at all.
Because of this, we can still consider CourseView a feature view; It still depends on business logic
(unlike the other views that have no connection to business logic at all). Except, now CourseView
indirectly depends on the Course model instead of directly.
11.3.3 A separation of concerns
CourseView is now separated one step from the business layer. There is no more tight-coupling
between CourseView and the Course model.
If we wished to, we could change the implementation of CourseViewModel without CourseView
needing any adjustments or awareness of the changes occurring behind the scenes.
As an example, CourseViewModel could return a hardcoded Course instance for testing purposes
without the awareness of CourseView.
Yet, in our case, by introducing a CourseViewModel we gain very little.
As mentioned earlier, we don’t consider CourseView a highly reusable view. We already made a lot of
effort to push most logic and complexity into the business layer. This keeps CourseView lightweight
and ensures the business logic code is mostly unit-testable.
If we were to introduce CourseViewModel, it would be extremely lightweight as well.
For these reasons, we can consider CourseViewModel a very shallow abstraction; It adds little value,
yet it makes our code more complicated by adding another layer of indirection.
It’s not worth the tradeoffs in this scenario, because of that, it’s better to leave it out.
In the world of declarative programming, without a distinct CourseViewModel, some may even
perceive CourseView as a viewmodel because it links business logic with the user interface.
Therefore, we classify this view as a feature view, to avoid mixing up terms.
11.3.4 Tradeoffs when using viewmodels
There are good reasons to introduce viewmodels or similar types; Such as when connecting one to a
reusable view, as it enables the view to remain entirely detached from business logic.
In other cases, they can help to keep a large view smaller.
237
Mobile System Design
Alternatively, some platforms demand it more. Viewmodels can be a default choice on Android because
they are lifecycle-aware and preserve state during configuration changes, such as when rotating a
screen.
Another reason to reach for a viewmodel is when the data from business logic isn’t ergonomic to use
for a specific screen or UI.
For instance, imagine that we didn’t have a neat Course model and CourseAPI. Instead, let’s imagine
we depend on a third-party Education SDK that supplies our data. This SDK knows nothing about
courses specifically.
In that case, to connect it to CourseView, we need a piece of code that transforms this data, making it
more easily consumable for our UI.
A CourseViewModel could handle the complex data transformation tasks, ensuring that the
CourseView remains lightweight and unaware of third-party solutions. This approach helps maintain
a clear separation between the business logic and the UI.
NOTE: This would be a tailor-made solution specific to CourseView. If you require a more generic
solution, push the code out of the viewmodel to the business layer, so that it can support multiple use
cases. Please refer to chapter 9 for more information.
238
Mobile System Design
11.3.5 Deciding if you need more layers of indirection
As a general guideline, consider introducing more layers of indirection, such as a viewmodel, when it
solves more challenges than it creates.
In the case of viewmodels, if it performs a lot of heavy lifting, then there is a justification to introduce
one. We introduce a new layer, the view would then indirectly rely on a business model.
But, in our case, CoourseViewModel would do minor work. This layer offers little yet adds more
complexity, or indirection.
For this reason, we’ll continue without requiring a viewmodel for CourseView.
11.3.6 Imperative considerations
Yet another reason developers rely on viewmodels or similar patterns, is because view-related code
can become massive in certain scenarios, such as in imperative code.
On iOS, for instance, we often bind views with data via viewcontrollers. A viewcontroller contains the
view and they are lifecycle aware. For instance, they can detect when the device rotates or when the
font size changes, and they can detect when the app is backgrounded and foregrounded. Since they
are a central point of a view, or "screen", developers tend to place business logic in viewcontrollers.
Due to their lifecycle awareness, the fact that they set up views, and contain a direct or indirect connection to business logic, viewcontrollers have a tendency to grow quite large and complex, occasionally
reaching thousands of lines if not managed carefully.
239
Mobile System Design
For that reason, developers are prone to move all business-logic out of viewcontrollers to reduce
their number of lines and complexity. Again, using various mediators to handle our bindings, such as
viewmodels or other patterns.
Taking business logic out of a viewcontroller is one approach to gain control of their complexity and
size.
But just because view-related code can grow large, doesn’t mean we always have to rely on viewmodels
or patterns with catchy acronyms. Let’s explore another approach.
11.3.7 Lean imperative code
We can make viewcontrollers quite lean using the same principles we used before, where we distinguish
feature views from view components. This allows us to give the role of viewmodels back to viewcontrollers while keeping them lean. As a result, we don’t need the viewmodel, keeping our code simpler
since there is one less layer of indirection.
We can achieve this with a slightly different mindset. Normally, viewcontrollers do a lot of heavy lifting
when it comes to setting up views. But there is no law that says we have to — unless you have a pesky
team lead that mandates you do.
With little effort, we can make a separate view and have the viewcontroller depend on it, instead.
240
Mobile System Design
That means that instead of adding more types and patterns to keep the viewcontroller small. We merely
take out its view.
NOTE: On iOS, we achieve this by supplying our own view in the loadView() method.
As a result, a viewcontroller now depends on a view, instead of being responsible for setting it up.
Now, a viewcontroller only has to worry about the view lifecycle. Once something changes, the
viewcontroller can update its view.
Next, because we focused a lot on pushing business logic out of the UI layer, the business logic is quite
“fat”.
That means there is little need for business logic in this UI layer. Therefore, we can make the viewcontroller directly depend on business logic and make the viewcontroller bind the model to the UI.
The viewcontroller’s new purpose is solely to glue business logic to a view and react to lifecycle changes.
It’s stealing back some of the responsibility of a viewmodel.
Most business logic lives outside of the UI domain and the view is now separate. As a result, the
viewcontroller remains lean and simpler.
Because a viewcontroller contains a view and glues it to business logic, we can classify it as a feature
view. This tells us more about its responsibilities.
If we were to apply this lean solution to our app, a newly introduced CourseViewController would
then depend on a separate CourseView. We could then demote CourseView from a feature view to a
view component, since it doesn’t need to know about business logic anymore.
241
Mobile System Design
11.4 UI Principle 11: Feature views aren’t always full-screen
We mentally divided up views into three classifications. A view primitive, a view component, and a
feature view.
But you may wonder why we don’t call a feature view a “screen”.
That means it’s time for the next philosophical question: What is a screen, really?
Mobile engineers and designers alike define a screen as the functioning UI on a device. Colloquially, a
“screen” uses up all pixels, points, or real-estate of a phone. It’s a device’s screen, after all.
For instance, in our case, the Course feature is something we would normally call a screen or feature,
because it takes up the whole device’s physical screen.
Unfortunately, this paradigm already breaks once views stop becoming full screen.
One instance of this is when we develop for bigger screen devices, such as phablets or tablets; Because
we often combine multiple “phone screens” to create a single “tablet screen”.
A classic example is a master-detail layout; On one side we have a list of elements, and on the other
side we can see each element in detail. Both are individual screens on a phone, yet together they create
a single full-sized screen on a tablet.
242
Mobile System Design
For instance, we might have a list of courses on the left, and the CourseView on the right. Now,
CourseView is technically speaking not a screen anymore, since it doesn’t use up the device’s screen.
Another example is when a “screen” can be a map view showing location markers. Although it might
occupy the entire screen in one context, the same map view could occupy only 50% of the screen
in another scenario. In such cases, the map view ceases to be a “screen” despite its identical visual
appearance.
This paradoxically implies that a “screen” isn’t always synonymous with a full-screen interface.
This suggests that when we call something a “screen”, it’s about it being a “feature”. Yet, that is not
always the case either. Let’s explore why.
243
Mobile System Design
11.4.1 Not every screen is necessarily a feature
Typically, when we talk about “screens” it infers they have business logic, but exceptions exist.
Sometimes, a “screen” might comprise a full screen alert view containing only a title and a button,
utilizing the entire screen but offering minimal functionality. In this book, we would classify such a
“screen” as a view primitive suitable for a UI library.
In other cases, a “screen” can be a photo picker. Which is a full screen view that presents albums of
photos. Despite it being full screen, it knows nothing about the feature it’s used in. In fact, we would
classify a photo picker as a view component that we can apply to numerous features.
This distinction emphasizes that the term “screen” is disconnected from a view’s functionality, and
that a screen isn’t always tied to business logic.
Instead of labeling full-screen views as “screens”, let’s think of views more in terms of features or
functionality. Then these views might be full-screen, or they might not. It doesn’t matter too much.
Hence why we use the term feature view to indicate that a view has business logic.
On a more technical level, when reasoning about the codebase, we try to avoid conflating the terms
“screen” with “views.”
In practical discussions, however, it is beneficial to use the term “screen” or "interface" when referring
to full-screen views or features. When talking to designers and team members, feel free to use terms
like “component,” “screen,” or “feature” to describe views that have full-screen functionality.
11.5 UI Principle 12: View components remain unaware of business logic
As a palette cleanser, we’ll continue as if we’re making a declarative CourseView again.
Before we can fully implement the CourseView screen, we need to know how we’re going to implement
the SelectionView component.
244
Mobile System Design
Because we classified SelectionView as a view component, it indicates that it has some logic that
we have to figure out.
We can’t merely pass it some strings and call it a day, like we could with view primitives such as labels.
We are dealing with two-way binding; Not only do we want Course to pass data to SelectionView so
that it could show the TODO items. Once the user taps on the TODO items, the state of SelectionView
changes and these changes should update the Course model.
Like CourseView, it has logic and data. But unlike CourseView, it does not know about the Course
feature. Yet, we want data from the Course feature in this component.
But, the problem is that we risk making SelectionView aware of courses. Since we classify
SelectionView as a view component, we don’t want it to know or “care” about business logic. In fact,
we made this explicit since we moved this component to a separate UI library.
Luckily, there is a simple solution.
In our case, we can introduce an interface (protocol in Swift terms) – let’s call it SelectionElement
– and make TODOItem conform to it. Now, SelectionView can work with TODOItem types, despite
not knowing about it.
Then we still have to solve the bindings. Because once a user taps on an element to complete or undo
a TODOItem, the Course model needs to be updated to reflect the last state. In the declarative realm
of SwiftUI, we can use a special @Binding keyword to have this solved for us. In the imperative realm,
we can use callbacks with closures.
We’ll cover both approaches when implementing the UI in the upcoming chapter.
245
Mobile System Design
11.6 Is making a reusable component worth it?
One downside is that we are investing extra effort to make SelectionView reusable so that it can live
in a UI library.
Some might say that we are over-engineering here. Because Course is the only feature that requires
this. As an alternative, it’s easier to make a TODOListView that uses TODOItem types. Then only
CourseView can use it, which would be enough to move on with our day.
Yet, the path we are taking in this book is different. We are introducing an interface and making
TODOItem conform to it.
Let’s go over some considerations why you may want to take either approach.
First, we’ll update the graph so we can reason about it better. Instead of a SelectionView in the UI
Library, we would end up with a TODOListView used by CourseView.
NOTE: I want to emphasize that this is an alternative solution. We will use SelectionView throughout
the book.
Looking at the graph, that would make this a feature view, because this component will now need to
know about business logic, such as TODOItem.
246
Mobile System Design
The view component took a leap upwards in the hierarchy. It went from living in a UI library, to becoming
a view that only the Course domain use, and now it contains business logic.
On one hand, our code is simpler since it can directly work with TODOItem instances. On the other
hand, we traded in a lot of versatility.
Let’s explore how we can reason about this.
11.6.1 Pitfalls of making a reusable component
Sometimes we may want to consider the probability of reuse. Such as asking ourselves "Will other
features need this component too?".
We would think in terms of probability. But unfortunately, we can’t predict the future very well, otherwise every software project would be completed with ease.
As a rule of thumb: Err on the side of not making components reusable for hypothetical scenarios.
If a component needs to be reusable, then make it reusable once needed.
However, that doesn’t mean we should never make reusable components upfront.
There is more nuance to it. In fact, in this book, we have been making reusable views upfront, going
against the general advice.
The goal would be to minimize risk. So that if we make a reusable component, and it turns out no other
feature needs it, then there should be little harm done.
Let’s explore how using a heuristic.
11.6.2 A heuristic for making reusable components
Suppose we invest a significant amount of effort to make this view highly reusable. If, later in time,
we learn other features do not require this component, then the heavy upfront investment means we
wasted time.
Likewise, let’s envision a situation where we spend minimal time to make our component reusable.
However, the component becomes considerably more complex because of it. Now, fast forward, and
we find out that other features don’t actually need this component. We’re stuck dealing with a complex
component for no good reason.
On the flip side, if it takes very little time and effort to make a component reusable and if it keeps the
complexity low, then it can be worth to make that investment. That is because we keep the risk low.
247
Mobile System Design
In our case, we predict that it’s likely this SelectionView will be used for something else. But that’s a
pitfall since we aren’t fortune tellers. Even if being able to select something is extremely common in
user interfaces, we might still predict wrong.
Instead, let’s consider the investments; We need to introduce a small interface, and now we turn a
feature view into a view component for a UI library. It’s a small time investment, the complexity stays
low (albeit subjective, it’s not an exact science). Yet, there is a big reward.
Since the investment is low and the complexity stays low, we could state that it’s worth this risk to
make SelectionView reusable from the start.
11.6.3 We placed most views in a UI library
Most views we made are reusable, and we placed them straight in a reusable UI library.
That is because with zero extra effort, these views became reusable straight away. All it took was to
rename them. We didn’t increase any complexity upfront for hypothetical scenarios.
Again, because the effort was so low (zero, really), and because the complexity remains the same, we
know that the risk is low to introduce them as reusable views.
248
Mobile System Design
11.7 Conclusion
Now that we’ve gained a clear understanding of the different views we’ve introduced, their respective
roles within the stack, and their knowledge of business logic, we can proceed with implementing
CourseView.
With this information in mind, we’ll begin the implementation of the CourseView and its subviews,
combining a declarative approach with elements of an imperative approach.
Mixing methodologies will demonstrates how the mental models discussed in this chapter can be
applied consistently across different frameworks. Despite the varying implementations, the approach
to setting up our views remains largely similar.
249
Mobile System Design
11.8 What we covered
In this chapter, we covered:
• Views can be classified in many ways. We classify them in three types:
– View primitives; These are simple views, such as labels or buttons. They have zero to no
logic. They are unaware of the feature(s) they’re used in.
– View components; These are smarter views, such as a map view, or a navigation bar. They
have more logic and/or data bindings. But just like view primitives, they do not know about
the feature(s) they’re used in.
– Feature views; They have logic and/or data bindings, and are connected to business logic.
• Classifying views in these ways helps us understand their purpose, data connections, logic, and
their location (e.g., within a feature or in a UI library).
Bindings:
• Feature views can have local view components that are part of a feature. As a recommendation,
make these components self-sufficient so that you can move them to a UI library with ease, if
needed.
• There are numerous ways to connect views to business logic. One common example is viewmodels, but there are many more.
• Introduce an abstraction, such as a viewmodel, when they offer enough benefits. Because adding
more types and layers makes a codebase more complicated to understand.
• On some platforms, viewmodels can be the default choice. For instance, on Android, viewmodels
are life-cycle aware and they survive configuration changes.
• In other scenarios, viewmodels can be a good bridge to connect business logic with a view.
Especially if the viewmodel has to perform some heavy lifting to make the data to work with a
view.
• To keep imperative view code smaller, see if you can use separate views and design “fat” business
models. Instead of pushing a lot of code to view-related patterns.
Reusable views:
• If a view is used only once, then, as a heuristic, make it reusable only when it takes low effort and
if you can keep the complexity low.
• If it takes low effort to make a view reusable, and if the complexity stays low, then consider make
a view reusable, even for a single purpose. Because the risk remains low.
• If you introduce a simple view primitive, it can often directly move to a UI library, since they
commonly are reusable with no extra effort.
250
Mobile System Design
Screens:
• Commonly we call full-screen views or features a “screen”.
• Views we call a “screen” aren’t always full screen, ironically. For instance, multiple screens on a
phone can make up one “tablet screen”.
• Conversely, a full screen view, or “screen”, might have smaller dimensions in another feature.
Thus, a “screen” stops becoming full screen.
• In practical discussions, use the term “screen” to depict designs and features of full-screen views.
• On a technical level, think of views more in terms of features or functionality. How large they are
and whether they are “full screen” is less relevant.
251
12 Pragmatically implementing UI
In this chapter
• Covering various approaches to implement UI
• Implementing UI declaratively
• Implementing UI imperatively
• Keeping a view’s body high-level
• Reasoning about views on a code-level
First make it work, then make it pretty.
We’ve covered a lot of theory behind UI. Now that we have a plan of attack, we will implement the
CourseView using the (sub)views that we defined.
This chapter assumes that you already are implementing UI regularly. Its focus is not on the how, but
rather on the rationale behind the why when making decisions. This chapter’s primary focus is to show
you how to deliver UI fast. Because it’s often more useful to get something working before we focus on
details.
For that to happen, first we will reason about various approaches and their trade-offs.
We’ll start by considering a top-down approach as well as a bottom-up implementation approach.
Then we’ll mix it and see how Holistic-Driven Development can fit in this process – as explained in
chapter 2.
For the most of the time, we’ll use SwiftUI to implement the UI. But with the risk of sounding like a
broken record: It isn’t too important that we use SwiftUI or UIKit or Flutter or Jetpack Compose. It’s
more important that you understand the concepts behind these decisions.
In fact, in this chapter, we’ll implement the SelectionView component twice. Once declaratively
using SwiftUI, and once imperatively using UIKit, both are frameworks offered by the iOS platform.
The reason that we’ll implement it twice is not only so that you can pick your favorite implementation.
This is to show you that, even though the paradigms differ quite a lot, our approach is extremely similar
regardless of the paradigms.
253
Mobile System Design
The point of that is, that no matter which paradigm you prefer, you can apply the UI principles from
this book. You will need only one plan regardless of which codebase (or interview) you’re in.
Still, we need to implement UI using something. Hence why we’ll favor a declarative approach since it’s
more modern and not as verbose as imperative UI.
Unlike the previous chapter, this chapter is code-heavy. Its goal is to show you the completeness of the
solution and deeper considerations on an implementation level.
That does mean this chapter becomes more iOS centric. However, if you’re more interested in the
concepts related to Mobile System Design, then feel free to merely skim the code listings.
254
Mobile System Design
12.1 Beginning the implementation
To start, let’s pull up the design again to refresh our memories.
Our goal is to implement this UI and its related subviews that we defined in earlier, such as TextButton
or SelectionView. For that reason, we will focus on this screen itself – which we classified as a feature
view – minus the chrome around it.
NOTE: The term "chrome" in UI is the user-interface surrounding content or screens. Such as a tab bar, or
navigation bar on mobile.
255
Mobile System Design
12.2 Considering various approaches
In the previous chapters, we have been defining the subviews required that make up CourseView. By
dissecting CourseView, we end up with lots of views of which some could live near the feature itself,
or in the UI library.
However, that doesn’t always mean we should decompose a view before implementing it.
Let’s consider various approaches to implement CourseView.
12.2.1 A top-down approach
We could take a top-down approach, where we first implement CourseView, after which we then
extract the subviews as needed.
Especially with declarative UI it’s relatively painless to define and extract views into their own subviews
because we can (de)compose views into separate views with ease by cutting and pasting the view
code.
Conversely, with imperative code, it’s generally a lot harder to extract views from a larger view, since
we can’t freely cut out and paste views as easily. For this reason, I recommend to at least define (not
necessarily implement) the subviews upfront for imperative UI.
One pitfall that comes with a top-down approach is that you may accidentally tightly-couple the views
to business logic. Such as making SelectionView depend on TODOItem directly, in our case. Because
256
Mobile System Design
we would be intertwining all view code before considering the possibility of extracting views.
It’s just something to keep in mind, because there are no hard barriers to prevent tight-coupling, since
the separate views aren’t extracted yet.
Hence why classifying views as feature views, view components, or view primitives can help you decide how much responsibility they should have. This keeps you mindful whether you’re accidentally
promoting or demoting a view from a primitive, to a component, or a feature view that knows about
business logic.
12.2.2 A bottom-up approach
Second, we could take a bottom-up approach, where we implement subviews – such as view primitives
in our UI library – before implementing CourseView, which rests at top of the hierarchy.
Defining all subviews in code beforehand– such as view components or view primitives – code can help
create hard-barriers and ensures we aren’t accidentally coupling data – such as Course data – to a
tiny reusable view. This is a useful approach and works especially well when working on the same UI
with a coworker, since you’re creating API barriers between views.
A downside is that it is more ceremonial upfront, as opposed to a top-down approach where we first
make something, and figure out how to extract subviews later.
We would perhaps focus too much on the details as opposed to prioritizing the CourseView implementation.
For our goals, it’s more important to get CourseView in a functional state because it’s the main screen
we’re implementing. It’s less important to fully finish its subviews.
12.2.3 A holistic approach
Third, we could approach this holistically, where we do create the subviews upfront. But we merely
use placeholder implementations for them, such as rectangles or labels. Then we would write a real
implementation for CourseView, using the subviews containing placeholders.
After we finish CourseView, we move one abstraction lower, delete the placeholder implementations
of the subviews, and replace them with the actual implementation.
This approach strikes a delicate balance between setting up all views that we need, yet we keep focus
on implementing CourseView.
257
Mobile System Design
12.2.4 A mixed approach
All aforementioned approaches are valid and useful. Use the one that works for you.
In this chapter, we will assume a mixed approach; We will assume the subviews are already created so
that we can focus on CourseView first. But, they are implemented holistically, where they will use
placeholder implementations.
However, there is a twist. All UI will roughly look like the design, but we won’t use correct margins,
fonts, or spacing.
In essence, we are using placeholder views. But, these placeholder views are more than simple shapes.
They would roughly look like the design. This way, these views will be decent looking enough so that
we get a good feel of the UI implementation, without us having to spend a lot of time fiddling with UI
details.
The reason we implement UI roughly is that it’s more important to get this screen working first. As
opposed to, say, focusing on the borders, margins, and shiniest drop shadows. If we do, then we would
get distracted by focusing on details.
Using a rough implementation allows us to focus holistically on implementing CourseView. We will
focus our time on combining views, connecting them with data, and fitting this screen into our app
with proper functionality.
We’ll add some colors since that will help mimic the UI design with little effort. But, for the rest, we’ll
use standard fonts, margins and icons.
Inside the UI code, you’ll find color notations such as .brandingPurple, or other variations. These
colors are added to the project outside the code in an asset library. Xcode, our editor, will rename a
color such as BrandingPurple to a compile-time checked property such as .brandingPurple.
NOTE: This is similar to the R class system in Android.
Here you can see how the colors are defined in Xcode’s assets library.
Remember, with a holistic approach, we are “sketching” a lot, and figuring out how to glue everything
together, so that we don’t get lost too much by the details.
After we’ve implemented CourseView, we’ll look at the implementations and considerations of the
other (sub)views that we defined in the previous chapters.
258
Mobile System Design
12.3 How we’ll load the data
In this chapter, we’ll make CourseView support courses that we pass in a hardcoded manner – as
opposed to loading them from inside CourseView.
This is because CourseView needs to work with a Course model, regardless where it’s loaded from,
which is why we prioritize merely passing one in.
To keep momentum, we’ll make sure that the avatar loads in a hard-coded way, sidestepping network
loading for now.
After the screen works with a Course model, we can deliberate out where to load it. In the next chapter,
we’ll explore multiple ways to make this UI self-sufficient so that it can load itself.
It’s worth noting that in the Holistic-Driven chapter, we didn’t fully complete all aspects of the model
logic, specifically regarding the sorting and filtering of TODO lists. We intentionally left this as an
exercise.
Nevertheless, in this chapter, we’ll proceed as if the model logic is finalized to align with the designs,
avoiding placeholders like "lorem ipsum" or odd behavior related to TODO items.
12.4 A SwiftUI crash course
This book will not be a SwiftUI tutorial book. But to ensure we are speaking the same language, let’s
take a little crash course to explain some important SwiftUI elements that we’ll use.
If you’re not using SwiftUI in your daily work, then don’t worry about specifics too much. The concepts
behind our approach is more important than code details.
There are a couple SwiftUI views that are good to know about:
• ScrollView allows us to scroll the UI if it becomes larger than the screen.
• VStack allows us to align views vertically.
• HStack allows us to align views horizontally.
• ZStack allows us to place views on top of each other.
• Spacer fills up empty space between views.
• Padding adds space around a view.
• Divider draws a divider line.
Besides SwiftUI’s views, we use our own views that we defined earlier:
• ThumbDescriptionView to show the tutor’s avatar, name, and handle.
• SubduedButton, a small button to message the tutor.
259
Mobile System Design
• TextButton, a simple text-based button.
• CalloutView, the speech bubble with positive reinforcement from the tutor.
• ScheduleView, displays the next 1on1 schedule.
You’ll also see something called actions. These are the methods that are called when a user presses on
a button. We’ll focus on the actions later in this chapter, because first we have to make sure we render
something on the screen.
12.5 Starting with CourseView
Looking at the upcoming CourseView implementation, we can see all the aforementioned views
without the TODO List – which we’ll save for last since it’s more intricate.
We are using @Binding var course: Course in SwiftUI. The @Binding keyword infers that an
outside source will pass Course to this view. Since it’s a binding, if we update the Course model in
CourseView, this change will mutate the Course not only in CourseView, but in its owner (parent)
as well.
Alternatively, we could pass CourseAPI which returns Course. Then an outside source would pass
CourseAPI, instead.
But, our priority now is to make this screen work with a Course instance, no matter how it’s retrieved.
We can’t support loading courses if we can’t even render a course. So let’s focus on supporting a
Course that we pass.
As per SwiftUI rules, we define a view inside its body declaration.
As a reminder: To focus on CourseView, we continue as if we already implemented the view primitives.
We’ll cover those implementations last.
1 // The CourseView struct, conforming to the View protocol to indicate
it can be used by SwiftUI
2 struct CourseView: View {
3
// @Binding to indicate that course is passed in, and if we mutate
it, its parent is updated.
4
@Binding var course: Course
5
6
// The body is the main view code of CourseView.
7
// The 'some' keyword depicts that it can return any type of View
8
var body: some View {
9
// We can scroll
10
ScrollView {
11
// We lay out elements vertically
12
VStack {
13
// Tutor info, laid out horizontally.
260
Mobile System Design
14
15
16
HStack(alignment: .top) {
ThumbDescriptionView(imageName: "Avatar",
title: course.tutor.
displayName,
description: course.tutor.
handle)
Spacer()
SubduedButton(action: openMessage, label: "Message"
)
}.padding([.horizontal, .top], 20)
17
18
19
20
21
22
23
// Only show callout when there is a value
// Since course's callout can be nil, as indicated by
the '?'.
if let message = course.callOut?.value {
CalloutView(message: message, action:
dismissCallout)
.padding(.vertical, 10)
}
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42 }
// A horizontal line
Divider()
.padding(.horizontal, 20)
}
}
}
// The scheduled info for a 1on1 call
ScheduleView(date: course.calendarEvent?.date,
actionJoinCall: joinCall, actionReschedule:
openScheduler)
.padding(.vertical, 10)
// .. Actions are omitted
261
Mobile System Design
The result is half a screen with real data and proper colors. But, it contains faulty margins, default
icons, and default fonts.
Notice that we apply the same "first make it work" principle to the other views. For instance,
CalloutView is a "rough" version, and has no arrow, and a default icon supplied by the system
font.
262
Mobile System Design
12.6 Simplifying view code
Looking at the body variable, I’d argue that each view is quite readable, except the tutor section gets a
bit convoluted.
Thanks to declarative views, we can move the view with ease, since we don’t need to refactor a lot
once we do. We’ll create a local property called tutorView and move the related tutor code there.
Then inside the body, we’ll refer to this new tutorView property.
1 struct CourseView: View {
2
@Binding var course: Course
3
4
var body: some View {
5
ScrollView {
6
VStack {
7
// We use a new tutorView property
8
tutorView
9
10
// The rest of the body remains the same
11
if let message = course.callOut?.value {
12
CalloutView(message: message, action:
dismissCallout)
13
.padding(.vertical, 10)
14
}
15
16
Divider()
17
.padding(.horizontal, 20)
18
19
ScheduleView(date: course.calendarEvent?.date,
actionJoinCall: joinCall, actionReschedule:
openScheduler)
20
.padding(.vertical, 10)
21
22
}
23
}
24
}
25
26
// We introduce a private view property called tutorView
27
// All tutor view info is now defined here.
28
private var tutorView: some View {
29
HStack(alignment: .top) {
30
ThumbDescriptionView(imageName: "Avatar",
31
title: course.tutor.displayName,
32
description: course.tutor.handle)
33
Spacer()
34
SubduedButton(action: openMessage, label: "Message")
35
}.padding([.horizontal, .top], 20)
36
}
37
263
Mobile System Design
38
39 }
// .. actions are omitted
In essence, the body now showcases the view’s sections on a high-level.
Note that we mark the new tutorView property as private, since no other view needs to know about
the tutorView. On top of that, we ensure that we don’t make it a shared, reusable, view, since
tutorView is very specific to CourseView.
NOTE: Alternatively, we could extract tutorView into its their (private) TutorView type. But, that has
a bit more overhead. Once a local view becomes more complicated (e.g. it require more bindings), it
makes more sense to extract them. But note that it may pollute the namespace a bit more. If you extract,
consider making it private to help keep the API of CourseView smaller and simpler to outsiders.
12.7 Implementing SelectionView declaratively
Now let’s go one layer deeper and implement the SelectionView component that we’ll use to build
up the TODO Lists.
We will implement SelectionView twice, once declaratively and once imperatively. To show you that
even though the UI paradigms differ a lot, the concept behind our approach is the same.
As discussed in this previous chapter, we have a SelectionView that uses an interface (protocol) to represent the data model, called SelectionElement. Then, using that interface, the
SelectionView can render a list of SelectionViewItem types.
This allows SelectionView to remain a reusable view component, as opposed to tied to one specific
feature.
Last, TODOItem can conform to SelectionElement.
264
Mobile System Design
SelectionElement can be a tiny interface, since we only need an id, title, and a boolean to indicate
whether it is checked off, or "completed".
One Swift and SwiftUI specific thing to note is that we make sure that SelectionElement types also
conforms to the Hashable and Identifiable protocol. In simple terms: This ensures that SwiftUI
can check the uniqueness of these elements to decide whether to redraw UI if SelectionElement
changes state.
To conform to the Identifiable protocol, we also need to add an id property, which is a unique
identifier for which we can use the UUID type.
NOTE: Notice that title and id are read-only properties – as indicated by the get keyword. But, the
completed property can be set as well, since it mutates when a user completes a task.
1 // Notice how, by conforming to SelectionElement protocol, a type also
needs to conform to Hashable and Identifiable protocols.
2 protocol SelectionElement: Hashable, Identifiable {
3
var title: String { get }
4
var id: UUID { get }
5
var completed: Bool { get set } // This property can be mutated, as
indicated by the 'set' keyword.
6 }
We then need to make TODOItem conform to this protocol using the extension keyword. Since
TODOItem uses the properties of the same name as SelectionElement, we don’t need to perform
any extra work, therefore the extension’s body remains empty.
1 extension TODOItem: SelectionElement {}
265
Mobile System Design
12.7.1 Defining SelectionView
Looking at SelectionView, it will use an array of SelectionElement types.
We use @Binding to indicate that the array of elements needs to be passed in, and that if we update
any of these elements, its owner – Course in our case – will also update.
The second property is an action that will trigger once a user taps on the accessory button. Its definition
is (SelectionElement)-> Void.
1 struct SelectionView: View {
2
3
// The list of SelectionElement types to render.
4
@Binding var elements: [SelectionElement]
5
6
// When an accessory button is tapped, we trigger this action
7
var action: (SelectionElement) -> Void
8
9
// ... snip
10 }
NOTE: If an anonymous function is hard to read, imagine it to be a named function, such as
func action(selectionElement: SelectionElement)-> Void { ... }.
This tells us that this function will receive a SelectionElement when it’s called and that it returns
nothing.
Now we’ll focus on the body implementation to finish this view.
Notice that we don’t need to pass an action to update the completion state of a TODOItem. As you’ll
see shortly, we can use bindings for that instead.
Looking at the body, we can use the ForEach view to render a list of elements. In our case, for each
element, we’ll define a SelectionItemView, representing a row.
NOTE: Not to be confused with a for each statement, ForEach is an actual view declaration to render
a list.
1 struct SelectionView: View {
2
3
@Binding var elements: [SelectionElement]
4
5
var action: (SelectionElement) -> Void
6
7
var body: some View {
8
// Turn a list of actions into a ForEach View.
9
ForEach($elements) { element in
10
// Inside the body of ForEach, return a SelectionItemView
for each element.
11
SelectionItemView(element: element, action: action)
266
Mobile System Design
12
13
14
15
16 }
}
}
.padding(.vertical, 4)
.padding(.horizontal, 10)
NOTE: You may have noticed that instead of passing elements to ForEach, we pass $elements prefixed
with a dollar sign. That’s SwiftUI’s way of passing a binding.
This code will not compile yet! But not to worry, we’ll make it work right after.
12.7.2 Compile-time versus runtime
We are nearing completion. But, unfortunately, SelectionView can’t work with SelectionElement
the way we just defined.
The Swift compiler needs to know at what each SelectionElement represents for this particular
situation. Currently, we are using SelectionElement dynamically, at runtime.
To solve this, we need to use generics to inform the Swift compiler that we’re dealing with
SelectionElement types during the compilation phase.
NOTE: If generics are a bit tricky to understand, not to worry. The key concept that you need to understand
is that SelectionView works with SelectionElement types. This generics situation is just a Swift
language detail.
Instead of directly using the SelectionElement protocol, we now define a new generic with a name
we can make up. Let’s call it Element. Next, we constrain it to SelectionElement, to tell Swift we
are working with types conforming to SelectionElement.
We do this by adding <Element: SelectionElement> in the definition.
1 struct SelectionView<Element: SelectionElement>: View {
2
// ... rest omitted
3 }
Now, inside the SelectionView body scope, we can refer to Element instead of SelectionElement.
The rest remains exactly the same.
1 // SelectionView uses Element instead of SelectionElement
2 struct SelectionView<Element: SelectionElement>: View {
3
4
// An array of Element types that SelectionView uses.
5
// Which really is an array of SelectionElement types.
6
@Binding var elements: [Element]
7
8
var action: (Element) -> Void
267
Mobile System Design
9
10
11
12
13
14
15
16
17 }
var body: some View {
ForEach($elements) { element in
SelectionItemView(element: element, action: action)
.padding(.vertical, 4)
.padding(.horizontal, 10)
}
}
This code will compile again. This time, the compiler knows about the types we are passing at
SelectionView.
This complexity is the price we pay for making SelectionView reusable.
On one hand, we can state that generics are complex and it makes it harder for newcomers to understand. On the other hand, once you’re comfortable making SwiftUI views, you’ll see that this is a
common way to work.
12.7.3 Implementing SelectionItemView
SelectionView renders a list of SelectionItemView types, one for each element.
We could just pass a title to SelectionItemView to fill its label. But, changing its state would not
update the TODOItem. We would need to do more gluing to make that work.
Instead, we could pass a SelectionElement again using a @Binding. This way, when we update the
element by toggling it, it automatically propagates the change up to SelectionView, all the way up
to the Course instance.This will then trigger an UI update in CourseView.
But, to reaffirm, SelectionItemView is not aware of TODOItems or courses.
To make this work, we’ll take the same approach as before.
We’ll have SelectionItemView depend on an Element generic, constrained to the same
SelectionElement protocol, similar to what we did with SelectionView.
Looking inside the body, we use an HStack to render this row of elements. It consists out of two
buttons. The first button is the icon and label and is used to check or uncheck the item. A user triggers
the second button by tapping the accessory icon.
1 struct SelectionItemView<Element: SelectionElement>: View {
2
3
@Binding var element: Element
268
Mobile System Design
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37 }
var action: (Element) -> Void
var body: some View {
HStack {
// The first button toggles the element.
Button {
// Trigger the element toggle when this button is
pressed.
element.completed.toggle() // Toggle the boolean
} label: {
// This button is represented as an icon and label.
// Depending on the element state, the checkmark is
either open or filled.
Image(systemName: element.completed ? "checkmark.circle
.fill" : "checkmark.circle")
.foregroundColor(element.completed ? Color(.
brandingGreen) : Color(.black))
Text(element.title)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 2)
.foregroundColor(Color.black)
.multilineTextAlignment(.leading)
}
}
Spacer()
// The second button triggers the action method. (When a
user taps the accessory icon)
Button {
action(element)
} label: {
// The button is represented as an accessory icon.
Image(systemName: "arrow.right.circle")
.foregroundColor(Color(.brandingPurple))
}
}.padding(.horizontal, 10)
12.8 Implementing SelectionView imperatively
Much like Sesame Street’s lesson, "We are different, we are the same," this section will illustrate that,
conceptually, the declarative and imperative approaches aren’t too different either.
We’ll implement SelectionView again, but this time we’ll use an imperative approach.
No matter which path we take, we can still work with a SelectionView, a SelectionViewItem, and
269
Mobile System Design
the SelectionElement protocol because we’re following the same basic principles.
Because we use the same principles, how we implement SelectionView then becomes an implementation detail for the majority.
But when we dive into the details, we do need to tackle the differences, which affect how our component’s API works.
One big difference is how we deal with two-way binding, especially since we can’t rely on the @Binding
keyword in imperative code to couple UI to data.
Typically, we pass a single action that triggers when an accessory is pressed, and the @Binding handles
state updates.
In this scenario, however, two-way binding doesn’t come automatically. To address this, we can provide
a second closure that triggers when an item’s completion state changes. Consequently, we have two
closures: one that triggers when the accessory is pressed and another that responds to changes in the
item’s completion state.
Now, it’s up to the owner of SelectionView to perform their magic and update the model accordingly,
which we’ll tackle as the final step.
12.8.1 A UIKit crash course
We won’t waste too much time on UIKit specifics since we only use it for this section in the book.
We’ll cover just enough to get a general understanding of how to implement the SelectionView
component imperatively.
It’s not important that you understand UIKit deeply. Again, it’s about understanding that the concepts
behind our approach are the same as before.
There are a few core differences to know about when implementing this component imperatively:
1. We manually set up views, including their constraints. These constraints inform UIKit how to
stretch and fill the available space.
2. We manage UI updates ourselves, they aren’t automatically triggered.
3. UIView types can have an intrinsic content size, which is a "default" size.
4. We need to manually add views and subviews to their parent views.
5. We have a regular initializer, pass arguments, then we call setupView() to configure the view.
We call the same method when the elements update to update the UI state.
We use the following types, offered by UIKit:
• UIView: The base view class used by UIKit. Other views inherit from this class.
270
Mobile System Design
• UIStackView: A UIView subclass, offered by UIKit, that automatically lays out elements vertically or horizontally.
• UIImageView: As its name implies, it draws an image on the screen. It uses the Image type to
do so.
• UIButton: A button, no surprise there. But what’s good to know is that it requires a target (a
class that will respond to button presses), and a selector, a reference to the method to call when
the user pressed a button.
Other specifics are added inline via comments.
12.8.2 Imperative code
Looking at the code, notice that we apply the same methodology; We pass the elements; we make
rows of SelectionViewItem types, and we pass the accessory action. Except now, we additionally
pass an action when an element is toggled.
The other difference is, of course, that our code is vastly different. But again, the approach is almost
the same as before.
Let’s start by looking at the imperative SelectionView, which renders a list of SelectionViewItem
types.
1 final class SelectionView<Element: SelectionElement>: UIView {
2
3
private let elements: [Element]
4
5
// Instead of one, we need two closures
6
private let actionDidToggleCompletion: (Element) -> Void
7
private let actionDidPressAccessory: (Element) -> Void
8
9
// We renew stackView on changes, because we can't update it on the
fly. Hence why this is optional, so we can reinitialize it, if
needed.
10
private var stackView: UIStackView?
11
12
// We now initialize with two closures.
13
init(elements: [Element], actionDidToggleCompletion: @escaping (
Element) -> Void, actionDidPressAccessory: @escaping (Element)
-> Void) {
14
self.elements = elements
15
self.actionDidToggleCompletion = actionDidToggleCompletion
16
self.actionDidPressAccessory = actionDidPressAccessory
17
super.init(frame: .zero)
18
19
// After initialization, we set up the view.
20
setupView()
271
Mobile System Design
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
}
// We give a default size to help lay out the element.
override var intrinsicContentSize: CGSize {
CGSize(width: 300, height: elements.count * 40)
}
// Once elements are updated, we trigger setupView()
func updateElements(_ elements: [Element]) {
setupView()
}
private func setupView() {
// Redraw stackview if needed
if let view = stackView {
view.removeFromSuperview()
stackView = nil
}
// Turn each element into a row view.
let views = elements.map { element in
SelectionItemView(element: element,
actionDidToggleCompletion: actionDidToggleCompletion,
actionDidPressAccessory: actionDidPressAccessory)
}
43
44
45
46
47
48
49
50
51
52
53
54
// Places views below each other
let stackView = UIStackView(arrangedSubviews: views)
stackView.axis = .vertical
addSubview(stackView)
// Tell UIKit we're setting constraints manually
stackView.translatesAutoresizingMaskIntoConstraints = false
55
56
57
58
59
60
61
62
63
64
65
272
}
// Lay out the view manually
addConstraints([stackView.leadingAnchor.constraint(equalTo:
self.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo:
self.trailingAnchor),
stackView.topAnchor.constraint(equalTo: self.
topAnchor),
stackView.bottomAnchor.constraint(equalTo: self
.bottomAnchor)])
// Inform UIKit we don't support Storyboards (Editor-based layouts)
// Because we lay out UI using code.
// This will crash when we load this from a Storyboard
required init?(coder: NSCoder) {
fatalError("Not implemented")
}
Mobile System Design
66 }
12.8.3 Implementing SelectionViewItem imperatively
Like before, SelectionView relies on SelectionViewItem; and just like before, SelectionViewItem
has the most UI-heavy code because it lays out the rows. Whereas SelectionView merely turns the
elements into SelectionViewItem types.
NOTE: For convenience, we use lazy properties. This allows us to keep their setup code close to their
definition. This helps keep setupView() smaller and simpler.
1 final class SelectionItemViewUIKit<Element: SelectionElement>: UIView {
2
3
private var element: Element
4
private let actionDidToggleCompletion: (Element) -> Void
5
private let actionDidPressAccessory: (Element) -> Void
6
7
init(element: Element, actionDidToggleCompletion: @escaping (
Element) -> Void, actionDidPressAccessory: @escaping (Element)
-> Void) {
8
self.element = element
9
self.actionDidToggleCompletion = actionDidToggleCompletion
10
self.actionDidPressAccessory = actionDidPressAccessory
11
super.init(frame: .zero)
12
setupView()
13
}
14
15
// A view to use for the checkmark.
16
// We use lazy to set up its state inside the property.
17
// This keeps the code near the component initializer itself.
18
private lazy var checkView: UIImageView = {
19
let imageView = UIImageView(image: nil)
20
imageView.tintColor = UIColor(resource: .brandingGreen)
21
// Keep proper aspect ratio
22
imageView.contentMode = .scaleAspectFit
23
return imageView
24
25
}()
26
27
private lazy var accessoryButton = {
28
let button = UIButton(type: .custom)
29
button.setImage(UIImage(systemName: "arrow.right.circle"), for:
.normal)
30
// Call the didPressAccessoryButton method on this view once
pressed.
273
Mobile System Design
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
}()
private func setupView() {
// Add button for check icon + label
let itemButton = UIButton(type: .custom)
// Call the didPressLabel method on this view once pressed.
itemButton.addTarget(self, action: #selector(didPressLabel),
for: .touchUpInside)
let label = UILabel(frame: .zero)
label.text = element.title
label.numberOfLines = 0 // make multiline
itemButton.addSubview(checkView)
itemButton.addSubview(label)
// Add accessory button
let stackView = UIStackView(arrangedSubviews: [itemButton,
accessoryButton])
stackView.axis = .horizontal
// Center align vertically
stackView.alignment = .center
stackView.distribution = .fill
addSubview(stackView)
53
54
55
56
57
58
59
60
61
62
63
64
65
// Update the state when the item is checked.
updateCheckedState()
// Set up all constraints
// Tell UIKit we're setting constraints manually
[stackView, itemButton, label, checkView, accessoryButton].
forEach { $0.translatesAutoresizingMaskIntoConstraints =
false }
66
67
addConstraints([checkView.leadingAnchor.constraint(equalTo:
itemButton.leadingAnchor, constant: 10),
68
69
70
71
72
73
74
274
button.addTarget(self, action: #selector(
didPressAccessoryButton), for: .touchUpInside)
button.tintColor = UIColor(resource: .brandingPurple)
return button
// .. snip to reduce constraint boilerplate
stackView.bottomAnchor.constraint(equalTo: self
.bottomAnchor)])
}
Mobile System Design
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99 }
private func updateCheckedState() {
// Update the image based on state.
checkView.image = UIImage(systemName: element.completed ? "
checkmark.circle.fill" : "checkmark.circle")!
}
// To support buttons we need to mark methods with @objc
@objc private func didPressAccessoryButton() {
actionDidPressAccessory(element)
}
@objc private func didPressLabel() {
// We update local state, but still need to propagate it up so
the owner can update accordingly.
element.completed.toggle()
actionDidToggleCompletion(element)
}
override var intrinsicContentSize: CGSize {
let size = CGSize(width: 200, height: 40)
return size
}
required init?(coder: NSCoder) {
fatalError("Not implemented")
}
12.8.4 Updating state changes
Now that we implemented SelectionView, we can configure it from its parent callsite, namely
CourseView.
Since it needs a closure instead of a @Binding, something else needs to update the todo items. In our
case, let’s make the owner of SelectionView manually update the todo items.
Inside the actionDidToggleCompletion we iterate over the schedule – which is the list of todo
items. While iterating, we build a new schedule using map.
For each todoItem, we check if it’s the same as the one we just toggled, and then we update it to
synchronize the state.
At the end, we update the Course model with the updated schedule.
275
Mobile System Design
1 SelectionView(elements: course.schedule,
2
actionDidToggleCompletion: { element in
3
// This closure is called when a user toggles an
element.
4
5
// Iterate over items and make a new array with the
toggled item.
6
let updatedSchedule = course.schedule.map { todoItem
in
7
// If the element is the same, make a copy with
new state, and return that
8
if todoItem == element {
9
var copy = todoItem
10
copy.completed = todoItem.completed
11
return copy
12
}
13
14
// Return the same item if it isn't toggled
15
return todoItem
16
}
17
18
// Update the Course's schedule to the
updatedSchedule
19
course.schedule = updatedSchedule
20
21
}, actionDidPressAccessory: { todoItem in
22
// open Details
23
})
That concludes the SelectionView component.
Next, we’ll implement the rest of the views declaratively. This paradigm is the default choice in this
book because it contains less boilerplate and represents a more modern approach.
276
Mobile System Design
12.9 Adding the TODO List
Now, we’ll implement the TODO List, consisting of two SelectionView components: one for weekly
and one for daily schedules. It will also display a title, ’This week’s schedule,’ a section header, ’Every
day,’ and a ’Reset all’ button.
We’ve implemented SelectionView, which represents a simple list.
However, the TODO List itself is a bit more intricate. Besides two lists (one for weekly TODO’s, and one
for daily TODO’s), it contains a title, section title, and a reset button.
The reason that SelectionView doesn’t contain the titles and reset button, keeps it more modular
and simpler to use – please refer to chapter 9 and 10 for details.
In order to create a TODO List, we’ll require a minimum of two SelectionView instances: one for
weekly and one for daily tasks. Additionally, the CourseView will take on the role of adding titles and
the reset button.
Because the definition of a TODO list is more detailed, we can opt to create a private property named
todoListView. Following that, we can examine how it’s implemented as a distinct property within
CourseView, and we can reference todoListView from within its body.
1 struct CourseView: View {
2
@Binding var course: Course
3
4
var body: some View {
5
ScrollView {
6
VStack {
7
277
Mobile System Design
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// .. other views are omitted
}
}
// We introduce a private todoListView that uses two SelectionView
instances under the hood.
private var todoListView: some View {
VStack {
// The section title and button
HStack {
Text("This week's schedule")
.font(.title2)
Spacer()
TextButton(label: "Reset all", action:
resetAllTODOItems)
}.padding([.horizontal, .top], 20)
// The weekly todo list
SelectionView(elements: $course.schedule.weekly, action:
openDetails(todoItem:))
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46 }
}
// Inside the body, we add a new todoListView
todoListView
// The daily subtitle
Text("Every day")
.font(.title3)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.top, 20)
// The daily todo list
SelectionView(elements: $course.schedule.daily, action:
openDetails(todoItem:))
Spacer()
}.background(Color(.brandingGrey))
}
// ... actions are omitted
Just like we did with tutorView earlier, moving the bulk of the definitions out of the body makes
it easy for folks to grasp the big picture of how the screen is put together by checking out the body
property.
But if they want to dive into the details of how the views is built, they can scroll down to see the
details.
278
Mobile System Design
The view now more closely resembles the design.
NOTE: Be sure to include the designer during this process, so they don’t clench their jaws when you show
them this rough screen. Show them that this is a work in progress and not the final result.
279
Mobile System Design
12.10 Implementing view primitives
To round up the implementation, let’s take a look at how the view primitives are implemented.
First, let’s begin with the view primitive that’s local to Course, namely ScheduleView, since it’s more
intricate than the other primitives.
One thing to note is that ScheduleView has a date property. Whether a date is set determines the
view state. If there is no date, we will show a different state.
We reflect this state in code by making the date property optional. It’s passed in, and depending on
the state, we show either a filledView or an emptyView. To keep the body simpler, we can extract
these two views. Like we did before. In this case, emptyView will be its own private property.
1 struct ScheduleView: View {
2
let date: Date?
3
4
let actionJoinCall: () -> Void
5
let actionReschedule: () -> Void
6
7
var body: some View {
8
VStack {
9
Text("Next 1on1")
10
.frame(maxWidth: .infinity, alignment: .leading)
11
.padding(.leading, 20)
12
.font(.footnote)
13
.padding(.bottom, 2)
14
15
HStack {
16
Image(systemName: "calendar")
17
if let date {
18
// We unwrap the date, and pass it to filledView
19
filledView(date: date)
20
} else {
21
// There is no date, show an empty view.
280
Mobile System Design
22
23
24
25
26
27
28
29
30
31
32
33 }
emptyView
}
}.padding(.horizontal, 20).padding(.bottom, 1)
}
}
TextButton(label: date == nil ? "Schedule" : "Reschedule",
action: actionReschedule)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 20)
// ... Rest omitted
But, filledView needs an unwrapped date to populate itself. One way to solve this, is making
filledView a function that accepts a date parameter, as opposed to making a view property. If
you’re wondering why filledView doesn’t use the optional date of ScheduleView, it’s because
we’d have to deal with an optional value which doesn’t make sense for a filled view. Whereas if we pass
it in, the date is always available, or "filled", for that method.
Note that this code will not compile yet, which we’ll fix shortly.
1 struct ScheduleView: View {
2
3
// This will not compile yet because we aren't returning one view!
4
private func filledView(date: Date) -> some View {
5
Text(date, style: .date)
6
Spacer()
7
Button(action: actionJoinCall, label: {
8
Text("Join call")
9
.foregroundColor(Color(.
brandingPurple))
10
Image(systemName: "arrow.
right.circle")
11
.foregroundColor(Color(.
brandingPurple))
12
13
})
14
}
15
16
private var emptyView: some View {
17
Text("Not set")
18
.frame(maxWidth: .infinity, alignment: .leading) // Left
align
19
}
20 }
281
Mobile System Design
12.10.1 View builders
However, we need to know one more SwiftUI detail: Normally, we could define a list of views inside
a body declaration. Then, SwiftUI performs some magic underwater – called Result Builders – to
combine all these views into one view that’s returned.
But we don’t get this functionality automatically on regular functions, such as filledView(date:).
In this case, the function doesn’t return one view, it defines a list of views.
We could explicitly make it return one view by wrapping all views in a container, such as a VStack. But,
then we are wrapping this view in another view without added benefits.
Fortunately, we can get the same behavior from body where we define a list of views, and SwiftUI turns
it into one view for us.
We can solve this by marking methods with the @ViewBuilder keyword to get the same functionality.
Next, we mark filledView(date:) with @ViewBuilder. We still declare a list of views without
having to wrap it in a container, and we don’t have to explicitly return anything.
1 struct ScheduleView: View {
2
3
// We apply @ViewBuilder so we can return a list of views as one
view.
4
@ViewBuilder
5
private func filledView(date: Date) -> some View {
6
Text(date, style: .date)
7
Spacer()
8
Button(action: actionJoinCall, label: {
9
Text("Join call")
10
.foregroundColor(Color(.
brandingPurple))
11
Image(systemName: "arrow.
right.circle")
12
.foregroundColor(Color(.
brandingPurple))
13
14
})
15
}
16
17
// ... snip
18 }
Now the code compiles again.
282
Mobile System Design
12.10.2 View primitives in the UI Library
The remaining views we haven’t looked at, are the view primitives living in the UI library. We worked
with the assumption that they are already implemented.
As the name implies, view primitives are simple views. We’ll look at them to be complete, but we will
not go in-depth because they are tiny definitions.
We’ll follow the same techniques and use of SwiftUI components as before.
1 struct ThumbDescriptionView: View {
2
let imageName: String
3
let title: String
4
let description: String
5
6
var body: some View {
7
HStack(alignment: .top) {
8
Image(imageName)
9
.font(.largeTitle)
10
.shadow(radius: 2)
11
VStack {
12
Text(title)
13
.frame(maxWidth: .infinity, alignment: .leading)
14
.font(.title2)
15
Text(description)
16
.frame(maxWidth: .infinity, alignment: .leading)
17
.font(.footnote)
18
}.frame(maxWidth: .infinity, alignment: .leading)
19
}
20
}
21 }
1 struct TextButton: View {
2
let label: String
3
let action: () -> Void
4
5
var body: some View {
6
Button(action: action,
7
label: {
8
Text(label)
9
.font(.footnote)
10
.foregroundColor(Color(.brandingPurple))
11
})
12
}
13 }
1 struct SubduedButton: View {
2
3
let action: () -> Void
4
let label: String
283
Mobile System Design
5
6
7
8
9
10
11
12
13 }
var body: some View {
Button(action: action) {
Text(label)
.font(.footnote)
.foregroundColor(Color(.brandingPurple))
}
}
NOTE: There is one new SwiftUI element here that we haven’t used before: ZStack, which allows us to
stack elements on top of each other.
1 // The CallOut or "Speech bubble".
2 struct CalloutView: View {
3
let message: String
4
let action: () -> Void
5
6
var body: some View {
7
// ZStack allows us to overlay views on top of each other.
8
// We do this to overlay the close button on top of the rounded
rectangle (as defined with the Text view).
9
ZStack(alignment: .topTrailing) {
10
Text(message)
11
.foregroundColor(Color.white)
12
.padding(.vertical, 10)
13
.padding(.horizontal, 23)
14
.background(Color(.brandingPurple))
15
.cornerRadius(10)
16
17
// The yellow close button
18
Image(systemName: "xmark.circle.fill")
19
.resizable()
20
.frame(width: 22, height: 22)
21
.foregroundColor(Color(.brandingYellow))
22
.offset(x: 10)
23
.offset(y: -10)
24
.onTapGesture(perform: action)
25
}
26
}
27 }
12.11 Implementing the actions
That concludes the views. Now that the UI is roughly in place, we have a decent-looking screen that
unfortunately doesn’t fully work yet.
Next, it’s time to finish the functionality.
284
Mobile System Design
Following the Holistic-Driven Development tradition, we dig deeper and add the necessary details to
finish this view on a functional level. This involves writing method implementations for the actions
within CourseView.
The components that we implemented were triggering certain actions.
For instance, the SubduedButton inside tutorView contains the action openMessage."
1 struct CourseView: Course {
2
3
private var tutorView: some View {
4
// We pass the openMessage action to the button
5
SubduedButton(action: openMessage, label: "Message")
6
// ... rest omitted
7
}
8
9
// ... rest omitted
10 }
These actions are methods that get triggered at certain points, such as pressing a button.
By passing actions, we can disconnect the views from Course, keeping them more reusable.
There are two actions that are specific to this screen, and we can implement them right away:
• dismissCallout(), triggered when we press the little X icon on the CalloutView to hide it.
• resetAllTODOItems()~, triggered when we press "reset" near the TODOs, which sets all TODO
items to incomplete.
The implementation updates the state of the course instance, and the UI responds accordingly by
hiding the CalloutView.
1 struct CourseView: View {
2
// ... snip
3
4
// These two actions will update the course's state. This state
will automatically trigger an UI update.
5
private func dismissCallout() {
6
// Removes the callout
7
course.callOut = .dismissed
8
}
9
10
private func resetAllTODOItems() {
11
// Resets all todo's
12
course.schedule.reset()
13
}
14
15 }
The other actions are related to navigation, which we’ll look at in a following chapter.
285
Mobile System Design
When dismissing the callout, it will disappear.
286
Mobile System Design
12.11.1 Preparing for navigation
Other actions, namely openMessage, openScheduler, joinCall, or openDetails, all trigger a
point in the flow.
To keep momentum, we can decide to implement these actions later. This is because they handle
navigation. But, right now, our focus is on this screen. What we can do is add a little placeholder that
will print the function, file, and line to the console whenever we trigger these actions."
1 struct CourseView: View {
2
// ... snip
3
4
// Navigation-specific.
5
private func joinCall() {
6
print("Implement \(#function) on \(#file), line: \(#line)")
7
}
8
9
private func openScheduler() {
10
print("Implement \(#function) on \(#file), line: \(#line)")
11
}
12
13
private func openMessage() {
14
print("Implement \(#function) on \(#file), line: \(#line)")
15
}
16
17
private func openDetails(todoItem: TODOItem) {
18
print("Implement \(#function) on \(#file), line: \(#line)")
19
}
20 }
For the navigation-related actions, when we trigger one, the console will print out the line where we
can implement it.
1 Implement joinCall() on /Users/tjeerdintveen/workspace/TutorApp/
CourseView.swift, line: 134
12.12 Conclusion
We can consider CourseView finished enough for this stage. It’s functional, connects UI to data, and
uses the proper subviews.
We defined the views that we needed, but we haven’t styled them. In this book, we won’t cover styling
UI, but consider giving styling a lower priority in real projects as well.
In the Holistic Driven Development chapter, we covered how it was important to connect everything
together by designing the APIs of our types. By using the placeholder implementations, we could keep
287
Mobile System Design
momentum and not get lost in the details.
Similarly, if we concentrate on the “bigger picture” by linking all UI components and providing a
functional application, even if it initially appears less polished, we can maintain our momentum.
Otherwise, we risk making the shiniest UI while we’re still refactoring and solidifying the requirements.
Apart from styling, this view still requires more work. Including secondary requirements such as
supporting accessibility, Right-to-Left support, dark (night) mode, dynamic font sizing, compatibility
with extra small and large devices, and more.
To consider CourseView finished on a functional level, we have to figure out who loads the course and
we have to finish the last methods containing implementation placeholders. Specifically, the actions
related to navigation. We’ll tackle both of these topics in the coming chapters.
288
Mobile System Design
12.13 What we covered
In this chapter, we covered:
• A top-down approach is useful for extracting subviews.
• One pitfall of a top-down approach is that it’s easy to accidentally tightly-couple a feature to a
subview.
• A bottom-up approach to implement a feature view (or screen), is useful to create hard barriers
between a feature view and its subviews.
• One pitfall of a bottom-up approach is that it’s easy to get distracted by the details of the subviews.
• You can use a mix of approaches. Such as a holistic approach where you define the subviews
upfront to avoid tight-coupling, but not fully implement them.
• To maintain momentum, it’s important to implement UI "functionally" first. Save the exact
margins and layouts until after you made the screen functional.
Implementing UI
• Keeping a feature view’s body more top-level makes it easier to reason about it.
• Consider creating local, private, subviews to keep the main view (body) higher-level.
– Extract local views into their own views once they become more mature or need more logic.
• Despite the different paradigms, we can implement an imperative component similar to a declarative one.
– One major difference in our approaches is that imperative code needs to compensate for
the lack of bindings.
– To compensate for the lack of bindings, an imperative component can offer a mechanic –
such as a closure – that triggers when a view is mutated. Then its parent can mutate the
data manually.
289
13 Delivering self-sufficient features, part I; The
art of staying nimble
In this chapter
• The benefits and the "why" of self-sufficient features
• Delivering features that we can implement in multiple locations
• Connecting and synchronizing loading functionality with UI
• Handling complexity through composition
• Handling errors and incorporating fail states
• Maintaining data integrity with custom bindings
• Reasoning about dependencies for self-sufficient features
Imagine, what if we can make features self-sufficient? Meaning that they don’t need any outside
help to function, a grown-up feature instead of a toddler feature. A feature that can tie their own
shoes.
In the previous chapter, we start to implement CourseView. In the following chapters, we’ll build
upon that by making it more mature and self-sufficient so that it can load itself, handle its own errors,
and, most importantly, we can easily move it around in our app.
This is precisely what we’ll dive into in this and the subsequent chapters.
Imagine features capable of functioning independently, without relying on their parent too much, if
at all. They can handle their tasks autonomously, with no need to be micromanaged by an ancestor
class.
Self-sufficient features offer flexibility, making it easier to integrate them into various app flows, even
unforeseen ones. If a feature isn’t bound to a specific location within the app, we can even move it to a
different module, if we so desire.
But that’s not all. A self-sufficient feature is a crucial ingredient in making complex screens more
manageable, as we’ll cover in an upcoming chapter. By enabling seamless embedding of one feature
into another, with no hassle, it substantially reduces the difficulty of delivering a complex screen.
291
Mobile System Design
Self-sufficient features streamline support for interruptive actions or deep linking. When you can
seamlessly present a feature at any point in any navigation, and it just works™, it makes implementation
much more effortless.
Another key aspect is ensuring features can gracefully handle errors and recover from them. When
features can handle their own issues rather than passing them to a parent, implementation becomes
smoother, reducing the burden on the parent component.
You could think of self-sufficient features as drag-and-drop components, widgets, or even standalone
SDKs that ‘just work.’
The question then is: ‘Is it worth investing extra time to make a feature self-sufficient?’
Fortunately, you can achieve these benefits without a significant upfront investment; It’s mostly about
awareness and avoiding shortcuts while implementing a feature. It also doesn’t mean you have to
apply every "self-sufficient principle" from the start.
In this chapter specifically, we’ll focus on high-level design considerations. In the following chapter,
we’ll continue on this concept by implementing CourseView in a self-sufficient manner, where we’ll
make decisions on a detailed code level. We’ll explore data loading, error handling, and data binding
to views.
Then, as we continue in the book, we’ll discuss navigation and how self-sufficient features fit into
various navigation contexts. We’ll also cover how they help the implementation of more complex
views.
292
Mobile System Design
13.1 The problem of dependent features
Before we dive into how to make features self-sufficient, let’s first explore why you’d want to do so.
Often, we encounter a feature-view, like a screen, where we pass in a value to display. This creates
a dependency, either explicitly or implicitly, between the owner (parent) of the view and the view
itself.
Let’s consider a classic example of a master-detail layout, also known as a two-pane layout. This
illustrates a common scenario where tight-coupling occurs: one view loads and displays a list, and
upon tapping an element, the other view presents the details of that element.
Now, imagine this master-detail setup displaying a list of courses on the left, and pressing on one
opens the details on the right using a CourseView we’ve previously implemented. We need to pass
an instance of Course data from this list to the detail view, CourseView in this case.
293
Mobile System Design
Whether intentionally or unintentionally, this results in a feature that’s incomplete on its own.
CourseView relies on a second type to function fully, because CourseView can’t retrieve its own
data; it depends on a list-view to supply a Course somehow.
But now, imagine we want to take the detail screen, CourseView in this case, and move it around to a
different flow. It won’t “just work” because it can’t load the data itself; it relies on the parent (the list)
to function fully. Now, this new location is also burdened with figuring out how to load a Course.
Similarly, if we want to open CourseView quickly from a deep link, it can’t "just work" because it lacks
the Course data it needs, all it might have is an ID. To offer deep linking in this scenario, we’d have to
recreate the master-detail hierarchy first, or come up with yet another solution to load courses. For
instance, the deep linking code may now need to load a Course itself before presenting a CourseView,
making the deep linking code a bit more complex.
This means the parent of CourseView must prepare and fetch the data.
On a local level, it’s relatively easier with declarative UI to grab a (sub)view, extract it, and move it
around. However, across flows involving loading and data, such as with feature-views or screens, it’s
typically more challenging to move a view around since it affects the app more on a structural level.
But wouldn’t it be nice if we can also easily cut-and-paste features around in our code-base?
Just like how, in real life, a parent needs to help their young child to get dressed; Sometimes in code, a
parent needs to help a child-feature – such as CourseView – to function.
But imagine, what if we can make features self-sufficient? Meaning that they don’t need any outside help
to function, a grown-up feature instead of a toddler feature. A feature that can tie their own shoes.
13.1.1 Error-handling
Another reason we might implicitly tightly couple a feature to a parent is when we don’t let features
handle their own errors, instead they require a parent to solve it.
Did the connection drop? Pass the error up to a parent.
Loading failed? Pass the error up to a parent.
The user made a mistake? Pass the error up to a parent.
And what does the parent view do? "Alright, I’ll simply display the error to the user with an alert."
The parent view may not always be equipped to handle a child’s error in the most effective manner.
There is typically more distance between the error and the parent view than there is between the error
and the child view. The parent view might lack the context or capability to handle the error. Typically,
that means the parent will present the error as an alert for the user to deal with.
294
Mobile System Design
It’s often preferable for a child-view to manage the error itself. This type is closer to where the problem
originates, for this reason they often have a more relevant context of the error as well.
By default, it should be the responsibility of the child-view to handle an error gracefully.
When a child has a minor scratch, it alleviates the parent if the child can grab their own band-aid. The
parent will still be involved, but doesn’t have to perform all steps to put the band-aid on.
The same goes with features. It’s nicer for the parent view (and developer) if the feature-view – the
child view in this case – can handle their own errors. The parent might be alerted to issues, but doesn’t
have to handle all of them.
13.1.2 Handling errors more gracefully
Many regularly-built features often lack a recovery mechanism. But, by offering one, you increase a
feature’s self-sufficiency.
For instance, consider a feature that involves uploading an image. While testing in a controlled office
environment with comfy air-conditioning and perfect network-connections, we may not always test
issues like connection drops in our apps.
However, users deal with a less-reliable app while on the go, such as when traveling via train, or while
spelunking in underground caves.
It’s more likely that a connection failure will occur in day-to-day life. But some apps would present a
big UI-blocking "The upload failed" prompt.
However, we can do better than merely presenting UI-blocking alerts. Let’s explore some alternative
approaches:
• The error could be presented inline with some text, as opposed to presenting a UI-blocking alert.
• The image uploading process could be queued. Then, once the connection restores, the app
will proactively continue the process. The app can then notify users via non-blocking toast
notifications.
• A feature could include retry mechanisms, where the data-layer signals an error to the featureview or screen only after several retries with specified delays.
• Alternatively, the feature could flag a failed data-upload operation. Then it could offer a retry
button in the UI; Similar to how messaging apps mark a failed message with an exclamation
mark on which users can tap to retry the operation.
• A feature could display a badge to the app icon or send a local push notification if the app is in
the background when the operation fails.
There are many alternatives beyond simply presenting an alert. They are great to get your feature up
and running, but aren’t always the best UX.
295
Mobile System Design
13.1.3 Multiple simultaneous errors
The problem of presenting alerts is exacerbated when dealing with multiple-errors at the same time.
Because only one alert can be displayed at a time.
Even if your app manages multiple alerts effectively by queuing them, consecutive UI-blocking alerts
can be disruptive and overwhelming for users who may not bother reading them all.
This issue is especially apparent when embedding views into a larger view, as each embedded view
may trigger its own alerts upon encountering an error.
Having multiple features battle over presenting alerts might cause bugs on the hosting screen.
Therefore, try to favor inline errors over alerts. Since they provide a more seamless experience and
help maintain the overall integrity of the interface.
Not to mention, it makes a view more self-sufficient since we can more easily embed them in larger
views. As opposed to a view that wants to hijack the parent view by presenting alerts on top of it.
13.2 A self-sufficient feature in technical terms
Now that we know why self-sufficient views are important, let’s explore what that means in a technical
sense.
There are key principles that we’ll apply:
1. Ensure that features are self-loading; By using ID lookup, views can load themselves even with
limited data. Views won’t need to depend on parent-views to fully function.
2. Ensure that features handle their own errors and fail-states; When views don’t eagerly
propagate errors to a parent, and when views present errors inline, it makes it easier to implement
or even embed them in other views.
3. Ensure feature-views work independently of their parent(s), or ancestors; When views
don’t need a lot from a parent, they can function more independently. As opposed to views that
require a lot of "back and forth" communication with their parent view.
4. Ensure a screen, or feature-view, is not in charge of how it’s used in navigation; We should
ensure that views don’t interfere with a parent’s navigation-stack. Otherwise, we end up in a
situation where multiple views are simultaneously battling to present or push views (and alerts)
to their parent’s navigation-stack.
This should ensure that feature-views, or screens, can move around freely in an app.
We’ll go over these steps in this and the following chapters, using our beloved CourseView.
296
Mobile System Design
13.3 Connecting data-loading to a feature
Now that we’ve covered the importance of self-sufficient features, let’s continue building upon
CourseView so that it too becomes self-sufficient.
First, we’ll consider some technical designs to connect the data-loading aspect to CourseView, while
making this feature as self-sufficient as possible. Then, in the next chapter, we’ll focus on the implementation details.
We have a working screen in the shape of CourseView, and we now support loading a course with
CourseAPI.
NOTE: In the next chapter, we’ll support ID lookup, too.
The next step is figuring out how to connect these two together. But, as we’ll soon discover, merely
passing CourseAPI to CourseView isn’t always the ideal solution.
In the previous chapter, we implemented CourseView so that it works with a Course instance that
we directly pass to it.
We prioritized it this way, because even if it were to load this course from somewhere, CourseView
still needs to populate itself with the Course data, regardless of where it’s retrieved from. That’s why,
in CourseView, we prioritized rendering a Course over loading one.
Inside the UI layer, we first have to realize that we are dealing with two major pieces of functionality to
consider the Course feature as self-loading.
1. We have to display, or render, a course. Which we solved with CourseView.
2. We have to connect the course-loading functionality – offered by CourseAPI – to CourseView.
Secondary requirements include:
a) Supporting a loading state
b) Supporting an error state
We will explore two approaches to combine these functionalities.
With the first approach, we extend CourseView, so that it supports passing CourseAPI to it. But, as
we’ll soon discover, passing CourseAPI to CourseView might not be the most favorable approach.
297
Mobile System Design
In contrast, by using the second approach, we use composition where we keep these functionalities
separate. Which will be the favored approach in this book.
13.3.1 Extending to add functionality
A straight-forward approach is to pass CourseAPI to CourseView. Then CourseView can load a
Course.
This means we make CourseView absorb the loading functionality, thus making it part of CourseView
itself.
Essentially, we would extend the CourseView with more functionality.
But this approach has some negative side-effects.
When we pass CourseAPI to CourseView to return a Course. That means the Course isn’t always
available inside CourseView, and we instantly see its downsides.
If CourseView can start loading courses, then there is a state where one is not there. Whereas before,
CourseView always had a Course instance, ready to populate its view.
Since Course is continuously referenced from inside CourseView, almost the entire CourseView
now needs to deal with an optional Course; Such as in the TODO List, the course’s tutor, as well as the
course’s schedule.
An optional value may not sound like a big deal, we deal with it all the time, after all. But if an entire
type hinges on it, then it affects everything inside of it. In our case, Course inside CourseView is
crucial, not a little optional value, so all references to Course are now affected.
That means we have to refactor the entire CourseView just to support the optionality.
298
Mobile System Design
13.3.2 Supporting optional data
If we were to move forward with this approach, then we would have to fix all compiler-errors and
ensure that all views can now deal with an optional Course data model. For instance, we might have
an empty version of the TODO List, of the tutor view, as well as the 1on1 schedule view. This requires
an updated design and creates a dependency on the designer.
Then, on top of supporting optionality everywhere inside CourseView, we need a loading state and
an error state for when loading fails.
Extending a type with new functionality is a common approach. However, it means we are making
CourseView larger and more complex.
But it doesn’t always have to be this way.
Instead of going deeper and extending CourseView and its sub-views, we can add this functionality
outside of CourseView. Let’s discover how.
13.4 Composition instead of extension
CourseView currently has one major job: to receive a Course and render it. Its secondary job is to
support mutating a Course by toggling TODO items.
But, just because we want to add functionality, doesn’t always have to mean we need to extend a type
by default.
Let’s consider yet another route; What if instead of extending CourseView, we leave it alone?
Then we don’t need to deal with optionality inside CourseView, and we don’t need to refactor
CourseView either.
That means the loading functionality needs to go somewhere else.
But, the problem is, we already covered how giving the loading functionality to a parent, such as a list
in a master-detail, is problematic. Because it makes a feature "incomplete". CourseView can’t easily
move around in the app if it can’t load itself. The feature would not be self-sufficient.
Let’s strike a balance; We will keep the loading functionality out of CourseView. But, we ensure it lives
right on top of CourseView. We achieve this by nesting CourseView with a new type that can load.
Then, from a "parent’s perspective", such as a list in master-detail, it can still deal with a single view
that can load itself.
299
Mobile System Design
13.4.1 Nesting views
To solve our conundrum, we’ll take the CourseView, and we encapsulate it by making it part of another
new view that has loading functionality; Let’s introduce a new view called CourseUI, which we would
classify as a feature-view, since it knows about a feature, specifically.
As a result, the loading functionality now lives on top of CourseView, inside CourseUI.
Instead of one larger CourseView, we have two smaller types to deal with the added complexity;
CourseView living inside of CourseUI.
CourseView becomes a sub-view itself. Yet it remains intact.
Now, other views would rely on CourseUI instead of CourseView. To outsiders, CourseUI now renders a course. Even though we know that, technically speaking, that work is offloaded to CourseView
underwater.
To put this into context. Imagine how the master-detail now uses a CourseUI with a nested
CourseView for its detail-pane. The master-detail is now oblivious that there is a nested view. It only
needs to concern itself with CourseUI.
300
Mobile System Design
NOTE: You might wonder why we nest a CourseView inside a loading view, such as CourseUI, and not
vice versa. This is because the loading view can then easily update between a loading and loaded view
state this way. We’ll cover its implementation soon where this will become clear.
13.4.2 Swapping the names for consistency
Honestly, the names can get confusing. First, the entire app (and other team-members) rely on
CourseView, and now everyone using it has to be told to stop using it and rely on CourseUI, instead. We can’t really justify it either, because we merely added one piece of functionality.
We might have solved the loading problem. But, for others in the team, this feature can appear volatile,
or unstable.
301
Mobile System Design
Imagine that we were delivering a core feature, such as a networking API, used by the entire team. It
doesn’t look great if other teams now have to refactor their code because we are introducing a new
type with “slightly” different functionality.
With API stability in mind, everyone can keep using CourseView if we swap its type name with
CourseUI.
This allows us, and all other components and team-members to still refer to CourseView. The original
CourseView functionality – renamed to CourseUI – is just pushed away deeper to make room for
new functionality.
The new CourseView (originally CourseUI) now delegates the rendering to a nested type – namely
CourseUI (originally CourseView).
We can consider CourseUI as the “pure” UI implementation.
But what’s really important is that, from an outsider’s perspective, CourseView hasn’t changed. For
outsiders, CourseView evolved, since it now offers loading functionality.
To outsiders, CourseView still renders a course. Even though we know that, technically speaking, that
work is offloaded to CourseUI underwater.
Since we kept the API similar, the perception changes from “ CourseView is volatile and because of
that, our team needs to spend time refactoring” to “ CourseView is maturing and the course-team
makes it easier for us to use it”.
This consistency is especially rewarding if the Course feature lives in its own module, because it keeps
the public API of this module more stable. As opposed to, say, having to remove CourseView from
302
Mobile System Design
the public API and making everyone use a newly introduced CourseUI.
NOTE: We could even choose to make CourseUI private to CourseView to make the feature’s API smaller.
E.g. If we choose to keep CourseUI in the same file and mark it as private, other types don’t even
know it exists.
Putting this into context again, we can see how the master-detail now has a CourseView in its detailpane, containing a nested CourseUI hidden from the master-detail.
303
Mobile System Design
13.5 Dependencies and self-sufficient features
Let’s take a moment to think about how self-sufficient features affect their dependencies.
Because once we start call a feature self-sufficient, one might think that they are completely decoupled
from a parent.
But just because we can move a feature around, it does not automatically mean that its dependencies
are now different and decoupled.
By moving a feature around in the app, its dependencies remains the same. They don’t need to be
completely decoupled.
For instance, if we were to move CourseView around in our app – such as from a tabbar to a masterdetail – then we only need to figure out how to pass a CourseAPI instance plus ID, in a different
location.
If passing dependencies to different locations is problematic, because there is too much coupling
between a parent and a view, then consider using factories that already have dependencies preloaded
in them.
This way, you only need to call a method that takes an ID, something like makeCourseView(id:).
Then you do not have to worry about passing other dependencies around such as CourseAPI.
Factories can help hide dependencies, placing fewer requirements on the parent to pass values, thus
making it easier to initialize a feature.
13.5.1 Modularity and encapsulation
In contrast, when moving a feature around across module boundaries, then a feature often requires
more decoupling. In that scenario, you do want to be more strict about which dependencies are
available and needed.
Because, not all dependencies might be available in that specific module right away. This decoupling
can often be achieved by introducing interfaces or closures.
NOTE: Please refer to chapter 7 and 8 for more information on how to achieve this. Chapter 8 is where
inter-module dependencies are handled in detail.
304
Mobile System Design
13.6 Conclusion
With that, we wrap up our exploration of high-level considerations and technical design.
Let’s now get into the nitty-gritty of implementing the loading functionality at the code level, using the
nested approach and exploring some other options along the way. We’ll take a detailed look at how
to link the CourseAPI to the CourseView component and how to handle potential errors. Although
data loading may seem routine, there are still plenty of decisions to be made, and we’ll cover them
all.
Following that in a subsequent chapter, we’ll focus our attention on the navigation aspect. Specifically,
we’ll explore techniques for decoupling a feature from a specific navigation context. This allows
you to integrate a feature-view or screen seamlessly into various flows and contexts throughout the
application.
Both steps will aid in delivering a more self-sufficient feature.
305
Mobile System Design
13.7 What we covered
In this chapter, we covered:
• Screens, or feature-views often need other types to fully function.
• We can make types more independent or “self-sufficient” by:
– Making them support ID lookup.
– Ensuring they can load themselves.
– Ensuring they can handle their own errors.
– Ensuring they can resolve their own errors before presenting them to a user.
– Decoupling their navigation.
• We often need ID lookup since types can’t always supply all data. Sometimes an ID is all we
receive, such as from opening a deep link.
Error handling and recovery
• Presenting an alert on an error is a quick and easy way to present an error, but it’s often not the
best way. Consider some alternatives, such as:
– Presenting an error inline.
– Consider queuing and resuming data-sync operations in the background.
– Consider using toast notifications on error when users navigate away from a screen.
– Consider using auto-retry mechanisms, as opposed to propagating an error on the first
failure.
– Consider marking data-upload operations as failed, and make it visible in the UI for a user
to retry.
– When an app is backgrounded when an error occurs, consider updating the app icon’s
badge or using a local push notification, instead.
Extension versus composition
• Just because we need new functionality doesn’t mean we have to break open a pre-existing type.
– We can add complexity by nesting views, instead of extending them. This leaves a view
intact.
• When nesting a pre-existing type with a new type, consider swapping names to ensure API
stability.
• A benefit of nesting types is that types can remain smaller.
306
Mobile System Design
Dependencies
• When a type becomes self-sufficient, it does not have to affect its dependencies.
• If moving around a type becomes cumbersome when its dependencies are not available in a
different location, then consider creating a factory where you can preload dependencies.
• When moving a type across module bounds, then the dependencies are more often affected,
since not all dependencies might be available in a specific module.
307
14 Delivering self-sufficient features, part II;
Self-loading features
In this chapter
• Reasoning about self-sufficient features that can load themselves
• How to support ID lookup and why it’s important
• Reasoning about various ways to glue data to UI
• Making a view more flexible by removing dependencies
• Maintaining API stability
• Reasoning about flexibility via closures
• Reasoning about views that load data granularly
We’ve already been loading data by defining the data models and logic for the Course feature. Now
it’s time to look at approaches to glue this to CourseView UI.
As mobile engineers, loading data and displaying it in UI is at the heart of our work. But, even if you’re
a seasoned engineer, we’ll look at various approaches we can take, because this affects how we make
features self-sufficient.
By making a feature self-sufficient, we make it capable of loading itself and enable it to handle its
errors independently where possible.
In this chapter, we will introduce a lightweight approach to glue the data logic to UI. Then, in the
upcoming chapter, we continue to iterate on our solution where we make a feature more portable so
that it works separately from UI.
First, we’ll look at the higher-level design. After that, we’ll cover ID lookup and why it’s important to
self-sufficient features.
Next, we’ll implement a new CourseView that seamlessly integrates the Course loading capabilities
to the CourseUI interface.
Following that, we’ll consider an approach to manage mutation from UI and how it synchronizes to the
backend.
At this stage, we’ll have a working solution. Before we finish up the chapter, we’ll take a brief detour to
see how we can make our UI a bit more flexible using closures.
309
Mobile System Design
To conclude this chapter, we’ll discuss how this approach will fit into views that need to load data at a
more granular level.
14.1 Where we left off
In the previous chapter ended up with a CourseView containing a nested CourseUI view.
NOTE: If you recall; We swapped the type names of CourseView to CourseUI. This ensures that to outsiders, we are still using a type called CourseView. We haven’t yet implemented the new CourseView.
CourseView loads a Course model instance, after which it would pass this to the embedded
CourseUI view.
310
Mobile System Design
To the parent – the master-detail view in this example – it appears as if the CourseView can both load
and render the UI for a Course model.
That CourseView delegates the rendering to CourseUI isn’t important to the parent. But, it makes
this feature more self-sufficient. It gives the illusion that CourseView can do it all!
Since CourseView shields its internals from its parent by hiding CourseUI, this makes this feature
quite self-sufficient. No matter in which flow we place it in our app, it can load and display a Course.
For instance, we could grab this CourseView, take out of this master-detail configuration, and place it
into any other flow, and it would “just work”!
It’s as if we can “cut and paste” CourseView. This allows us to be very agile.
In this chapter, we’ll get into the nitty gritty and figure out how we glue CourseView to CourseUI,
and connect it to the data-loading aspect via CourseAPI.
Let’s begin, shall we?
14.2 A lightweight approach to connect data to UI
There are a multitude of ways we could connect data to UI, as there are a ton of patterns and discussions
to be found online as a favorite pastime. But, in this book, we’ll focus on two fundamental ways.
One approach is to pass CourseAPI to CourseView. This will be the most straightforward, lightweight,
solution.
With this approach, we don’t need new types, no interfaces, no repositories or fancy patterns. This
approach is especially great for getting up and running. Especially if we are dealing with a read-only
view. But, we will hit a few snags, because it makes it a bit more tricky to handle mutation.
As is the case, Course can be mutated, because we can toggle its TODO items. This makes CourseView
not a read-only view.
In the next chapter, we continue to the second major way of loading data; By using a separate type
to bind Course to CourseView. That does not mean, however, that this is a better approach. We are
merely starting out as simple as possible and ending with a more elaborate solution. You decide what
works best for your use-cases.
Let’s look at the flow when using a lightweight approach. The parent sends an ID, after which
CourseView uses CourseAPI and CourseUI underwater to handle the loading and rendering of this
Course.
311
Mobile System Design
Notice how the parent sends one signal only: “Here is the ID”. Translated in API terms; it means the API
surface of CourseView is tiny because the parent has to do minor work. This small API surface aids in
how disconnected, or self-sufficient, CourseView becomes.
Imagine it was the opposite, where CourseView requires 20 back-and-forth method-calls via closures
or delegates to its parent. Then, if we were to move out CourseView to another location in the app, it
would be a laborious exercise.
Before we implement the new CourseView, we first need to update CourseAPI so that it supports ID
lookup. Let’s do that next.
14.3 Supporting ID lookup
For a long time, we continued with the decision to support a single course. We didn’t need an ID for
that reason.
But, often it doesn’t work that way.
312
Mobile System Design
There are usually two main reasons to support ID lookup:
1. A parent entity, such as a list, may only supply limited data to a detail view. Whereas a detailed
view may require a lot more data.
2. When a parent can’t offer any data at all (besides an ID). For instance, when presenting a view
from anywhere as triggered from an external deep link or push notification, then the data might
not be available. All you have is the ID from the external deep link.
We already implemented CourseUI, and we have a working CourseAPI. But, to support ID lookup,
we need to update CourseAPI.
Let’s design that now, so that we can use both CourseUI and CourseAPI to fully implement
CourseView.
Let’s assume we are supporting a master-detail solution as we covered last.
The list loads a list of courses, the detail renders the CourseView. But, that data in the list will not be
complete for a detailed view, such as CourseView.
Instead of loading a list of rich, fully filled, Course objects, the list will load CourseSummary objects,
each one containing nothing more than an ID, icon, title and description.
NOTE: We’ll use the UUID for the ID property, since they ensure uniqueness.
1 struct CourseSummary {
2
let ID: UUID
3
let icon: Data
4
let title: String
5
let description: String
6 }
The reason for this approach is that it doesn’t always make sense to load all data for each object in a
list, because rarely will a user ever need all data in every session.
Now let’s say we want to fill up, or populate, a CourseView. We could pass a CourseSummary to fill
up that view, but it’s missing some of the data, unlike Course.
For that reason alone, it can be fruitful for CourseView to support ID lookup, so that it can fully load all
required data. This allows us to navigate instantly to CourseView by tapping on a list element. Then,
CourseView can use an ID from CourseSummary to load a Course instance. Once CourseView is
done loading, it can fully display Course data by passing the loaded Course to CourseUI.
313
Mobile System Design
14.4 Maturing CourseAPI
Earlier in the book, we designed a CourseAPI that loads and returns a single course.
To support the new requirements, let’s mature it so that it can load CourseSummary types and support
ID lookup.
NOTE: Following the Holistic-Driven Development approach, we will introduce new methods, and we can
continue as if these methods are fully functional, whether they are production-ready is not important
to CourseView. Meaning, we don’t have to lose focus by worrying about their implementation at the
moment.
Originally, we would create a CourseAPI instance, and call loadCourse() on it to retrieve a Course
data model which we can use in CourseView.
NOTE: In the coming examples, we will explicitly annotate some types. Such as let course: Course.
The Swift compiler does not require this, but adding clarity to the examples will be helpful.
1 let courseAPI = CourseAPI(/* ... omitted dependencies ... */)
2
3 // We are adding the explicit type for clarity. Such as ': Course'
4 let course: Course = try await courseAPI.loadCourse()
Let’s now assume that we are further in the development process. Now, instead of returning a single
course, CourseAPI requires a course ID to load its data, this is to support loading a variety of courses.
An ID can come from many places. It could come from a deep link, or a locally stored value, or given to
us by some other view.
1
2
3
4
5
let courseAPI = CourseAPI(/* ... omitted dependencies ... */)
let ID: UUID = .... // We grab an ID from *somewhere*
// Then we load a course by passing the ID
let course: Course = try await courseAPI.loadCourse(ID: ID)
To support lists of courses, let’s now assume that we can load a list of CourseSummary types.
To retrieve them, we would call loadCourses() (plural) on CourseAPI.
1 let courseAPI = CourseAPI(/* ... omitted dependencies ... */)
2
3 // We retrieve a list of course summaries by calling loadCourses()
4 let courses: [CourseSummary] = try await courseAPI.loadCourses()
To retrieve the data for a single course, we could pass a CourseSummary ID to loadCourse() on
CourseAPI.
314
Mobile System Design
For example, we would make use of this when a user taps on a course summary on a list on a masterdetail screen, and we’d use its ID to load the actual course for the detail screen.
Below, we’ll load the summaries, grab the first one, and use its ID to load the full Course.
1
2
3
4
5
6
let courseAPI = CourseAPI(/* ... omitted dependencies ... */)
// We now call loadCourses() (plural)
let courses: [CourseSummary] = try await courseAPI.loadCourses()
// We get the first summary. We assume the array is not empty for this
example.
7 let courseSummary: CourseSummary = courses[0]
8
9 // Near the detail screen, we would load the full course by passing the
ID to loadCourse()
10 let course: Course = try await courseAPI.loadCourse(courseSummary.ID)
Now that CourseAPI and CourseUI (previously implemented) are ready, we can start implementing
the new CourseView!
14.5 Implementing the new CourseView
We already implemented CourseUI – formerly known as CourseView – which takes a Course and
renders the UI.
Now we’ll implement a new CourseView which uses CourseUI underwater.
Looking at the Course domain and its types, we can see that CourseView uses CourseUI; They both
depend on a Course model instance.
The main differentiator is that CourseView also has access to CourseAPI for loading mechanisms.
315
Mobile System Design
316
Mobile System Design
14.5.1 Three states
Since CourseView is in charge of the loading aspects, we can deduce that the new CourseView
requires three states:
1. A failure state that contains an Error. We keep it simple, and display a Text containing hte
error message.
2. A loading state to show the data is loading. We decide to keep it as simple as possible to maintain velocity. We’ll draw a spinner that animates, in SwiftUI terms known as a ProgressView.
3. The loaded state contains the loaded Course model. Once loaded, CourseView passes this
Course model is to CourseUI to render.
These states are prevalent in many mobile screens.
NOTE: We could model these states with an enum. But unfortunately, SwiftUI doesn’t allow for easy
bindings with enums. So we’ll avoid enums in order to get SwiftUI bindings for free.
Looking at the upcoming code listing, notice that CourseView now depends on a CourseAPI and ID
instance to load a Course model. We’ll supply these dependencies via the initializer.
Inside the body, we’ll check whether we have an error or course, or if both are nil. Using that
information, we’ll render the related view. Such as a Text to show an error message, a loader called
ProgressView, or CourseUI to render the actual Course model.
Notice that when the view’s state is in a loading state, the startLoadingCourse() method is called
from inside the body, this triggers CourseAPI to load by passing an ID, after which CourseView will
set the state to either error or loaded once finished.
Last, we’ll annotate startLoadingCourse() with the @MainActor keyword to ensure this method
runs on the main thread for UI updates.
NOTE: A SwiftUI refresher: With @State we show that a type (CourseView in this case) stores a property
that others can bind to. By passing the course property to CourseUI – as showed with a $course, we
pass a binding to CourseUI. This means that, if CourseUI mutates this Course instance, the @State
inside CourseView will update with it. Notice that we need to unwrap the binding using the if let
keywords. This is because the course property is optional, but CourseUI requires an unwrapped
course.
317
Mobile System Design
1 // We define a new CourseView
2 struct CourseView: View {
3
4
// CourseView depends on an ID and CourseAPI
5
let ID: UUID
6
let courseAPI: CourseAPI
7
8
// Either this view has a course or error.
9
// Hence why both are nil.
10
@State private var course: Course?
11
@State private var error: Error?
12
13
// The body renders one of three views.
14
var body: some View {
15
if let error {
16
// If there is an error, we show it in text
17
Text("Error: " + error.localizedDescription)
18
} else if let binding = Binding($course) {
19
// If there is a course, we need to unwrap it because
CourseUI requires it.
20
// We unwrap it into a new Binding.
21
// Then, we pass this binding to CourseUI.
22
CourseUI(course: binding)
23
} else {
24
// If we're in a loading state, we show a ProgressView()
25
// We also trigger a startLoadCourse() call.
26
VStack {
27
Text("Loading")
28
ProgressView()
29
}.task {
30
await startLoadCourse()
31
}
32
}
33
}
34
35
// This methods triggers loadCourse on courseAPI
36
// Then it either sets the loaded course or the error.
37
@MainActor
38
private func startLoadCourse() async {
39
do {
40
self.course = try await courseAPI.loadCourse(ID: ID)
41
} catch let error {
42
self.error = error
43
}
44
}
45
46 }
318
Mobile System Design
14.6 A self-sufficient feature in practice
We have a working CourseView! It can load its own Course using an ID only.
With this solution, we made the Course feature self-sufficient; Now, we can move CourseView around
in the app and it can load and synchronize itself, no matter where we place it in our app.
With little effort, we can present it from anywhere, all we need is to pass an ID and CourseAPI instance
to create it. Whether that’s in a master-detail or from a deep link, it will "just work".
1
2
3
4
let ID = UUID()
let courseAPI = CourseAPI()
// We have a self-loading CourseView
CourseView(ID: ID, courseAPI: courseAPI)
The only requirement is that we have to figure out how to pass CourseAPI and an ID to its initializer.
Even if loading fails, CourseView can handle itself. It does not need to bubble up an error for a parent
to present. Admittedly, CourseView renders an error text, which is a crude way to handle errors, but
with little effort we can spice up its design.
One big benefit of this composition approach, is that this keeps types very small. Notice that this new
CourseView is less than 50 lines including the comments, making it relatively easy to understand or
debug this view.
Another significant benefit is that the original CourseView – renamed to CourseUI – remains intact otherwise, yet we enriched it with more functionality. As opposed to "opening up" the original
CourseView, extending it with loading functionality, and thereby increasing its complexity and risking
that we break something.
Complexity doesn’t always have to come from growing a type. Complexity can come from
combining smaller types into something more sophisticated.
14.6.1 A deep linking example
To reinforce the benefit of self-loading views, let’s look at a small, albeit crude, example.
In the app we make, we display a MainView (undefined). On top of that, it reacts to deep links using
onOpenURL. Which gets triggered when the device responds to a link such as "tutorapp://opencourse?id=12344". If the deepLink property is set, the app will present a CourseView using the
sheet modifier.
1 @main
2 struct TutorApp: App {
319
Mobile System Design
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 }
// We use the deepLink property to decide if a link needs to be
opened.
@State var deepLink: DeepLink? = nil
var body: some Scene {
WindowGroup {
MainView()
// onOpenURL is triggered once an external url is opened,
such as tutorapp://open-course?id=12344
.onOpenURL(perform: { url in
// We initialize a DeepLink from the
incoming URL.
// The DeepLink initializer returns nil if
parsing fails.
self.deepLink = DeepLink(url: url)
})
// If deepLink set, we'll trigger a presentation and
display CourseView that can load itself.
.sheet(item: $deepLink) { deepLink in
let courseAPI = CourseAPI()
CourseView(ID: deepLink.ID, courseAPI: courseAPI)
}
}
}
Thanks to our hard work, we can easily move around CourseView. As showed in this deep link example.
We merely pass it two dependencies, present it, and it works right away!
14.7 Supporting mutation
Self-loading views are neat until we realize this data needs to outlive the current view. For example,
if we were to navigate away from CourseView and back again, CourseView would have to load the
Course again!
Luckily, CourseAPI already solve this by caching loaded courses underwater using a Store in the communication (networking) domain. Meaning that, if we load this Course again by opening CourseView,
CourseAPI can directly pass it to the view.
However, one shortcoming of our approach is that this doesn’t take mutation into account. Even
though we use the Course inside CourseView, navigating away causes the mutated state to be lost.
Worse, neither is an updated (mutated) Course synced with the backend; We’re only editing locally so
far.
This is because CourseView has a local Course state.
320
Mobile System Design
But not to worry, we can solve this with a tiny change on the UI side, which requires more work on the
data side.
Imagine that we can sync Course updates to the backend. Let’s assume that CourseAPI offers a
method for this called update(course:).
Then we need to ensure we can call this method as soon as we update the Course instance.
With SwiftUI, we can react to changes by using the onChange(of:) modifier. We’ll attach it to
CourseUI since that’s where the changes happen. Inside the onChange(of:) closure, we first unwrap
the optional course, and then we call the asynchronous update(course:) method by passing the
unwrapped course.
Note that onChange(of:) does not support asynchronous methods. Hence why we need to wrap it
in a Task. This Task can handle asynchronous methods and failure for us. In simpler terms: By using
Task, we can call asynchronous code from synchronous code.
1 struct CourseView: View {
2
3
// ... snip
4
var body: some View {
5
if let error {
6
// ... snip
7
} else if let binding = Binding($course) {
8
CourseUI(course: binding)
9
// If course changes...
10
.onChange(of: course) {
11
// ... we try to unwrap course. But, if it's nil, we
return
12
guard let course else { return }
13
// course is unwrapped here (not nil) thanks to the
guard.
14
15
// onChange doesn't support asynchronous methods
16
// which is why we wrap it into a Task.
17
Task {
18
// In here we can call courseAPI.update(course:)
asynchronously
19
try await courseAPI.update(course: course)
20
}
21
}
22
} else {
23
// ... snip
24
}
25
}
26 }
How the courseAPI.update(course:) method works is not important from the UI code’s perspective. Using our Holistic-Driven Development approach, we can assume it "just works".
321
Mobile System Design
We can imagine that this method will be smart enough to know when to sync with the backend, to avoid
hammering millions of updates. Perhaps it will batch changes, maybe even store them for updating in
the background.
For now, we can offer a placeholder implementation to make everything compile. Then we can either
implement update(course:) later in a future sprint, or implement it in parallel by bribing a coworker
with a coffee.
Note that we aren’t handling errors here for when update(course:) fails. We have some ways to
handle this; We could update the Task closure and deal with errors there, or we could handle errors in
the background and depict them using a toast notification, for instance.
IMPORTANT: One key component to be aware of is that the error for loading a Course, is not the same as
the error for mutating a course. For instance, if we were to update a course, and on failure, set the error
on CourseView, then the UI would remove CourseUI and depict the error. Instead, we might want to
use alerts here or toasts for when mutation fails.
14.8 Increasing flexibility by removing dependencies
As long as we can offer a Course or CourseAPI instance with an ID, we can present a CourseView.
But, it can feel a bit heavy to pass an entire CourseAPI dependency, when really all we need is one
method or function to give us a course.
To CourseView, it doesn’t matter how it gets a course, just that it gets one somehow.
With one minor change, CourseView can support anything that returns a course, not just CourseAPI.
We achieve this by replacing CourseAPI with a closure that returns a Course.
This will help make CourseView a bit more flexible and not directly dependent on the CourseAPI
dependency. Before we consider the trade-offs, let’s explore how that would look.
14.8.1 Replacing a property with a closure
Instead of using CourseAPI directly, CourseView will load a course via a closure that returns a
Course.
The closure’s signature would be () async throws -> Course, which tells us that when the closure
is called, it will return a Course.
It loads asynchronously, as indicated by async, and it can throw errors, as indicated by throws.
322
Mobile System Design
Notice that we don’t need to pass an ID anymore to load a Course. Because the type that defines
the closure, will define how a Course will loaded, and that might not always need an ID, as you’ll see
shortly,
We can attach this closure to a property named loadCourse. This replaces the original courseAPI
property.
1 struct CourseView: View {
2
let loadCourse: () async throws -> Course
3
4
// This property is not needed anymore.
5
// private let courseAPI: CourseAPI
6
7
// ... snip
8 }
Looking at the view again, we can see that it now uses the loadCourse closure instead.
NOTE: If you recall, @escaping indicates that the passed closure outlives the method. It’s retained as a
property in our case.
1 struct CourseView: View {
2
3
@State private var course: Course?
4
@State private var error: Error?
5
6
private let loadCourse: () async throws -> Course
7
8
init(loadCourse: @escaping () async throws -> Course) {
9
self.loadCourse = loadCourse
10
}
11
12
var body: some View {
13
if let error {
14
Text("Error: " + error.localizedDescription)
15
} else if let binding = Binding($course) {
16
CourseUI(course: binding)
17
} else {
18
VStack {
19
Text("Loading")
20
ProgressView()
21
}
22
.task {
23
await startLoadCourse()
24
}
25
}
26
}
27
28
private func startLoadCourse() async {
29
do {
323
Mobile System Design
30
31
32
33
34
35
36
37 }
}
// We don't need to pass an ID anymore.
self.course = try await loadCourse()
} catch let error {
self.error = error
}
Now, CourseView completely lost its connection to CourseAPI. It’s not a dependency anymore.
14.8.2 The closure in action
To use our new API when initializing CourseView, we would pass a closure that loads the course.
To show off the flexibility, we’ll go over a few use-cases.
In the first example, we’ll load a course via CourseAPI again. This closure captures, or retains, the
CourseAPI, so that it stays in memory long enough for the API call to complete. Notice how we still
use an ID, but it’s not required to pass that to CourseView anymore.
1 let courseAPI = CourseAPI()
2 let ID = ... // We grab an ID from somewhere, e.g. a deep link or a
list of CourseSummary types.
3
4 CourseView {
5
// The closure will call loadCourse and return a Course object
6
let course = try await courseAPI.loadCourse(ID: ID)
7
return course
8 }
Because of our change, we can now pass anything that returns a Course, not only network calls.
In the next example, we can load courses from a hypothetical Course database.
1
2
3
4
5
6
let courseDatabase = CourseDatabase()
let ID = ...
CourseView {
try await courseDatabase.loadCourse(ID: ID)
}
Yet another example showcases the added flexibility; We can load courses in a hard-coded way, such
as during UI Tests. Below, we make a course using a CourseMock factory. This allows us to set up a
hard-coded course to be used when running UI tests.
1 CourseView {
2
try await CourseMock.makeCourse()
324
Mobile System Design
3 }
4
5 // Alternatively, point-free style.
6 CourseView(loadCourse: CourseMock.makeCourse)
These are just a few examples of the increased flexibility we get without relying on CourseAPI.
14.8.3 Trade-offs when using a closure
In the end, we don’t have to replace the CourseAPI dependency with a closure. But, know that it is an
option.
Since we only needed one method from it, it was a lightweight change that gives us tons of flexibility in
return. But, the downside is that if we eventually do require more methods from CourseAPI, we’d
be introducing an interface after all, or adding more closures, or we’d be reverting it back to directly
passing CourseAPI.
However, it will not be a big change. Reverting this change is a relatively small exercise. Which is
something that is often overlooked. People are averse to the “wrong abstraction”, but consider the
price of reverting a change. In this case, it would be a simple revert – remove one closure as opposed
to, say, removing 5 generic types and interfaces – which means the risk is low.
You can decide if it’s worth this trade-off.
Another downside is that this flexibility can be less beginner-friendly; Interpreting closures might be
second nature to some. But to others, a named method is easier to understand. It depends on your
team.
If you’re concerned about making CourseView testable, note that as we covered in the testing chapter,
we can get a more system-wide testing approach by mocking out on at the networking level, not at
the feature-level. Meaning that to test CourseView, we don’t have to use this closure approach. We
would mock out closer to the networking-layer, instead.
One last trade-off is that by default, CourseView would work with CourseAPI. But, by looking at
CourseView’s API, it’s not clear that this is the default dependency. CourseView merely requires a
closure. Let’s solve that problem now.
14.8.4 Restoring API stability
Because of the closure approach, if someone would type "CourseView(" and wait for the autocomplete to tell you which dependencies are required, they wouldn’t know. Because CourseView doesn’t
depend on CourseAPI anymore. However, for 90% of the cases, CourseView probably requires
CourseAPI (and ID).
325
Mobile System Design
We could state that CourseView is too flexible or too detached from CourseAPI.
Besides that, we also broke the API. Call sites that want to initialize CourseView with the CourseAPI
and ID will now not compile.
We can get a best-of-both-worlds situation where we can initialize CourseView via a closure, or via
passing ID and CourseAPI instances.
To solve this, we’ll re-add the initalizer that takes an ID and courseAPI instance, just like before. But
now, inside the initalizer, we’ll refer to the initializer that contains the loadCourse closure.
Basically, we are forwarding the initializer. This way, we don’t have to store the CourseAPI, yet we
support the original API when loading courses.
1 struct CourseView: View {
2
3
// We define an initializer that accepts an ID and CourseAPI like
before
4
init(ID: UUID, courseAPI: CourseAPI) {
5
// But instead of storing the ID and CourseAPI properties, we
refer to the initializer that requires the loadCourse
closure.
6
// We define the closure by calling loadCourse on courseAPI.
7
self.init(loadCourse: {
8
try await courseAPI.loadCourse(ID: ID)
9
})
10
}
11
12
// ... snip
13 }
To outsiders that are initializing CourseAPI, it’s as if nothing has changed. And developers who are
wondering what dependencies CourseView needs can still see in their auto-complete that they can
initialize CourseView by passing an instance of CourseAPI and ID.
14.9 Loading data granularly
Currently, the loading state solution swaps between an “all or nothing” type of loading screen. Either
CourseUI is loaded, or it’s not.
But, you might wonder how our solution would work when we load data granularly.
For instance, let’s imagine that instead of CourseUI, we are working with a CourseListView. This
type depicts a list of courses and offers a pull-to-refresh mechanic to load more courses.
To make this work using loading states, we have some approaches.
326
Mobile System Design
First, we could still use a CourseView – or similar type – to offer a loading screen for when CourseList
is completely without data. There is nothing to show, so CourseView could handle the initial loading
for CourseList and render a nicely styled empty state.
Then, after CourseAPI has loaded some courses, CourseView can add CourseList to the
screen. Then, CourseList itself can offer the pull-to-refresh loading mechanisms to load the rest.
CourseList will offer this to load data manually for a user, as opposed to automatically with
CourseView.
Alternatively, you could offer an empty state in CourseList itself, then you wouldn’t need a
CourseView or similar type. This means that CourseList becomes somewhat more complicated.
Last, CourseView doesn’t always have to be a three-state loading view. Sometimes you might need
four or five loading states. If needed, we could expand CourseView more intricate logic and states.
These are just some suggestions to approach other scenarios.
Whichever approach you prefer, the takeaway is that as long as you can make a feature self-loading, it
will become more self-sufficient and thus easier to move around in an app.
14.10 Conclusion
We have implemented a self-sufficient CourseView feature while leaving CourseUI intact. This is a
big win. Not having to update types to increase functionality helps progress and prevents introducing
bugs or regressions.
They also have distinctive roles. CourseUI renders a Course and handles its mutation from a user, such
as checking off TODO items. Whereas CourseView focuses on the loading aspect and synchronizing
data with the backend.
Our feature is lightweight, because we only introduced a small CourseView using no extra models,
viewmodels, or other types. On top of that, our feature supports loading data and mutation.
We also made sure that we can load a Course using only an ID, which aids in making a feature selfsufficient. As long as we have an ID and CourseAPI, we can present and load a Course from anywhere.
It’s like opening a linked webpage: It just loads.
Last, we explored how we can make this view more flexible by introducing a closure. Even though
it might feel like we are making our view overly flexible, we still keep API stability by reintroducing
the original initializer. If, in the future, it turns out we’ve been over-engineering, then luckily we can
quickly revert this change with minimal effort.
However, we have been stretching the boundaries. We had to implement the mutation mechanism
manually, which now lives inside CourseView.
327
Mobile System Design
Because of this, CourseView has become a source of truth for our data.
In the next chapter, we’ll explore a more formal approach, where a model type becomes a source of
truth instead of CourseView, allowing our Course feature to become more portable. In other words:
The Course feature will become more standalone and not strongly tied to UI.
328
Mobile System Design
14.11 What we covered
In this chapter, we covered:
• If a feature can load itself, it becomes more self-sufficient, since a parent class doesn’t have to
handle the loading work.
• If a feature works by merely passing an ID, then it becomes easier to move around in an app,
and it aids deep-linking.
• Working with ID types is common because not all data will always be preloaded for a feature.
• By keeping the API surface of a feature small, it unburdens a parent to initialize a feature. This
makes it easier to “cut and paste” a feature to different locations in an app. This promotes a
feature’s self-sufficiency.
• Commonly, views that load data end up having three main view-states; A loading state, an error
state, and a state with data.
• By connecting data-loading and data-updating to a view, we risk making a view the source-oftruth of our data.
Removing dependencies
• If a type depends on a single method from a single dependency, then we can replace that with a
closure.
– A closure offers more flexibility than a direct dependency.
– A closure is more general, and it can hide a type’s commonly used dependencies.
– A closure is less beginner-friendly and can be harder to interpret.
• When using a closure as dependency, we can maintain API stability by offering an initializer that
accepts commonly used dependencies. This initializer can then set up the flexible closure.
Granular data loading
• A screen commonly has three loading states.
• Some screens require more granular loading. Such as a list view with a pull-to-refresh mechanic.
– In such a situation, you can offer an empty loading state to this list view, at the expense of
added complexity.
– Alternatively, you could use a separate loading view to handle the initial empty state of a
list view.
– After loading the initial data via a loading view, the list view can then granularly load the
remaining data.
329
15 Delivering self-sufficient features, part III;
Making features portable
In this chapter
• Reasoning about portable features
• Moving more code out of the UI into the model domain
• Reasoning about source-of-truths
• How to detach a feature from UI
• How portable features support tests better
• Using portable features to support various UI paradigms and patterns
Portable features: Features that work across platforms, independent of UI patterns, UI architectures, or UI paradigms.
In the previous chapter, we connected the loading logic and states from the Course feature to UI.
As a result, the loading states live in the UI. This causes the UI to become a source-of-truth for the
Course feature.
This approach is great for a single platform, pattern, or paradigm; We got up and running quickly with
a lightweight solution.
In this chapter, we’ll continue to make this feature more portable – meaning it can work across platforms,
regardless of UI paradigm, UI pattern, or UI architecture.
One reason it’s important to deliver portable features is because it helps make a feature more selfsufficient. We can more easily move it around our app. Not just from flow to flow, but now also across
platforms, targets, and architectures.
We covered this topic on a higher-level in ’Chapter 9: UI frameworks, architectures, and supporting
multiple products’. To take this idea one step further, we’ll take a closer look in this chapter at how to
apply this concept to the Course feature on a detailed level.
But that’s not all. A portable feature helps us survive UI migrations.
331
Mobile System Design
Because over time, we tend to migrate from pattern to pattern, such as from MVC to MVVM. In contrast, for larger projects, a codebase will often consist out of various patterns. Because teams might
experiment with new patterns or UI architectures for specific features. Similar to how you may have a
Java codebase, and then one team will write a new feature in Kotlin. Or how you might have a UIKit
codebase, and implement a new feature in SwiftUI.
These UI migrations seem inevitable even though they are often self-inflicted. Although occasionally,
UI migrations are pushed upon us, such as Apple and Google pressuring us to switch from UIKit to
SwiftUI or from XML to Jetpack Compose, or when a power-hungry team-lead enforces everyone in the
company to use the latest, trending, pattern.
All these migration-processes are exponentially more difficult if a feature is not portable, because a
feature would be tightly-coupled to a specific UI implementation, making it harder to swap to a new
pattern.
But that’s not the only reason we may want a feature to become more portable; We may decide to
suddenly offer features to new platforms, such as a Watch app or VR app, which is substantially more
challenging if a feature is tightly-coupled to a single paradigm. Yes, some paradigms are multi-platform,
such as SwiftUI. But that’s doesn’t cover non-UI platforms and targets.
On top of that, the more feature-logic we place in the UI domain, the harder it becomes to run our
code in the background. But perhaps a bigger issue is that it becomes increasingly difficult to unit-test
a feature. Meaning we either resort to UI tests or manual checking to verify if a feature works. Most
developers I interviewed do not write UI tests, and manual checks are hard to automate and replicate
across tons of devices.
So we take a risk: The less portable a feature is, the more likely it’s untested.
There are even more benefits to making features more portable, which we’ll cover in this chapter. Such
as how it enables us to detach and re-attach a feature from and to its UI.
Making a feature portable is not a binary "all-or-nothing" choice either; It’s a gradual process. We can
take more and more out of the UI domain, and move it into the model domain.
As we’ll cover in this chapter, we can’t make a UI feature fully portable since we do need UI. But we can
get really close.
To improve our situation, we’ll start pushing some logic out of our feature – CourseView specifically –
and move it into the model domain. We’ll be moving the source-of-truth out of the UI domain.
Even though making a feature portable is a simple, but an often overlooked concept. It might not
always be easy to achieve, so let’s use this chapter to improve our understanding and make the Course
feature more self-sufficient and portable, so that our codebase becomes more flexible.
332
Mobile System Design
15.1 Where we left off
Let’s continue from the previous chapter and iterate. As a refresher, we’ll look at our current situation.
Looking at the dependencies, we can see that CourseView depends on CourseAPI and CourseUI
to load and display a Course model.
333
Mobile System Design
Currently, CourseView loads a Course using CourseAPI and an ID. After that, CourseView renders
the Course using CourseUI – which we implemented in ’Chapter 12: ’Pragmatically implementing
UI’. After the user mutates Course using CourseUI, CourseView triggers the update(course:) on
CourseAPI to ensure the data is in sync with the backend.
15.1.1 Why the Course feature currently isn’t portable
Notice that in this sequence diagram, the actions and types are grouped below markings called either
‘UI domain‘ or ‘Model domain‘. Visually, the weight is mostly on the left side.
We can also deduce that CourseView is the brain of the operation; Most actions are connected or
surrounding CourseView. It loads and stores a Course instance, and decides when to trigger updates
334
Mobile System Design
to CourseAPI.
NOTE: Not depicted is all the work that CourseAPI does behind the scenes. But, in this context, we focus
on gluing the Course feature to UI. For that reason, we omit the intricacies of CourseAPI.
One important distinction is that CourseView is the source-of-truth, or owner, of the Course data.
Even though CourseAPI returns a Course instance, it’s CourseView that stores it. Not only that,
CourseView passes Course to CourseUI via a binding, and responds to its mutations.
This way of working got us up and running quickly, but it hinders its portability, since we depend
heavily on the UI domain, specifically CourseView, for our feature to function.
Perhaps subconsciously, we ended up in this scenario because CourseAPI can load any Course
as long as it gets an ID. But nothing in the model domain offers a way to store or bind to a single
Course. Hence why CourseView takes on this burden of binding to a single Course. But, as a result,
CourseView becomes the source-of-truth of our data, inside the UI domain.
To mitigate this, we will soon push some logic out of CourseView, out of the UI domain, and into the
model domain. We will move more actions towards the right. This enables us to deliver our feature in
a more portable and unit-testable manner.
15.1.2 Why we currently can’t unit-test Course as a system
If we want to test the loading aspect where we load a single Course via an ID and bind to it, then we
can only test this manually or by writing UI Tests for CourseView, which are notoriously more difficult
to write and slower to run.
Yes, we can test loadCourse(ID:) on CourseAPI to test loading a single course. But, we aren’t able
to unit-test that the Course feature is in a successful or erroneous state after loading a single course.
Currently, CourseView has that responsibility by inspecting the return value of this method, and
reacts by setting the appropriate Course or Error state.
Second, we are unable to unit-test that CourseView calls courseAPI.update(course:) after a user
updates (mutates) a Course. Conversely, we can unit-test the update(course:) call on CourseAPI.
But again, we would only be testing this method in isolation, not as part of a larger system where this
call is triggered after a mutation.
In its current state, we would have to write UI tests or verify this manually, because CourseView uses
SwiftUI’s onChange mechanic to respond to updates, which we can’t easily unit-test because it lives in
the UI domain.
NOTE: For more information about the importance of system-wide testing, please refer to ’Chapter 4.
System-wide testing; Delivering higher quality apps’.
335
Mobile System Design
15.2 Pushing the logic out of the UI domain
The cure is simple: We ease CourseView from some responsibilities by pushing more business logic
out of it. This business logic in plain English is “The feature loads a Course, and when a user updates a
Course, the backend should be updated, too”.
Let’s consider an approach for the Course feature.
We could move the logic to CourseAPI, which prevents us from introducing more types. However,
as its name implies, I think CourseAPI should focus on API calls, not to mention that it can handle
loading a bazillion courses with various IDs. In our case, we need something to load and bind to a
single Course instance and deal with its mutation. For that reason, let’s introduce something new to
not over-complicate CourseAPI.
NOTE: Every scenario is different and you might want a different approach for your apps; The key takeaway here is that we move logic to the model domain.
15.2.1 Introducing a source-of-truth model
We can increase the portability by creating a new type that handles the loading and mutation for us.
This way, CourseView can be extremely lightweight and focus solely on connecting the corresponding
state to UI. Let’s call this new type CourseService which will handle the loading and error states and
mutation for us for a single Course.
Looking at the class diagram, we introduce a new CourseService. As a result, the dependencies
change where CourseView depends on a CourseService for its data. CourseView doesn’t store
a Course anymore, it merely connects or binds CourseService (model domain) to CourseUI (UI
domain).
336
Mobile System Design
To show a broader context, we can see how these same components live together in the Course
domain.
337
Mobile System Design
Notice that we explicitly place the new CourseService into the model domain, not the UI domain.
Otherwise, we harm its portability and testability. For instance, if we were to make CourseService
part of SwiftUI, we could not place this logic in unit-tests, or a different UI framework.
The new CourseService can be a source-of-truth for a single Course – something that goes hand-inhand with declarative UI since it’s easier to bind to. But, it also suits other paradigms, because having
the source-of-truth outside of the UI is an excellent way to decouple data from the UI domain.
Remember, a few chapters back, how imagining a feature as a Command Line Tool can help us reason
about where to place logic? This is the same principle; We place as much of the business logic that we
can into the model domain.
NOTE: This is also why we don’t call CourseService a “ViewModel”. CourseService is completely
oblivious to views, and we can even use it for non-view code.
338
Mobile System Design
15.2.2 Moving logic to the model domain
Looking at the following sequence diagram, we can see that CourseView now solely relies on
CourseService to deal with data-loading and mutation. CourseService offers a single Course to
bind to. The only responsibility of CourseView is to handle the loading states and pass the Course
binding to CourseUI, once loaded.
As we can see, we moved more of the work to the right into the model domain. The UI domain concerns
itself with state changes and bindings only, but not business logic such as "When a course is updated,
the backend must be kept in sync."
Our latest approach allows us to keep the UI domain lightweight and ensures that we make the feature
more portable.
In order to solidify our understanding, let’s look at what our new solution would look like in code.
Following that, we’ll review the benefits we gained.
339
Mobile System Design
15.2.3 Implementing CourseService
First, we’ll define the new CourseService. It will take on some responsibility from CourseView,
where it will take an ID and CourseAPI instance, and uses this to load a Course.
Then CourseView can then bind to its error and course properties.
Previously, CourseView relied on the onChange modifier to react to changes. But now, we can detect
changes via a didSet method in CourseService, which is Swift’s way – not SwiftUI’s way – to trigger
some code once a property is updated. This is a good thing, because Swift runs on more platforms and
targets than SwiftUI. Inside this didSet method, we verify that the ID’s are correct, and we unwrap the
course.
NOTE: We mark the CourseService class as @Observable. This allows SwiftUI views such as
CourseView to update once any property on CourseService changes.
1 @Observable
2 final class CourseService {
3
4
private let ID: UUID
5
private let courseAPI: CourseAPI
6
7
// Because CourseService is @Observable, CourseView (or any type
really) can bind to the error and course properties.
8
var error: Error?
9
var course: Course? {
10
didSet {
11
// We unwrap the previous value called oldValue, which is
something Swift supplies in didSet.
12
// We unwrap the course, too.
13
// Then we verify that the ID's are still in sync. Just to
be defensive.
14
if let oldValue, let course, oldValue.ID == course.ID,
course.ID == ID {
15
// We are confident that the course is updated, so we
can call the appropriate method.
16
Task {
17
try await courseAPI.update(course: course)
18
}
19
}
20
21
}
22
}
23
24
// The rest is similar as before in CourseView
25
26
init(ID: UUID, courseAPI: CourseAPI) {
27
self.ID = ID
28
self.courseAPI = courseAPI
340
Mobile System Design
29
30
31
32
33
34
35
36
37
38
39 }
}
@MainActor
func startLoadCourse() async {
do {
self.course = try await courseAPI.loadCourse(ID: ID)
} catch let error {
self.error = error
}
}
That concludes CourseService. next, we’ll update CourseView.
15.2.4 Simplifying CourseView
We can start simplifying CourseView, because CourseService takes on some of its responsibilities.
Before, CourseView was in charge of binding to a single Course or Error, thus making CourseView
the source-of-truth for a single Course. With this new solution, CourseView is not that source-of-truth
anymore, that responsibility shifts to CourseService. The sole purpose of CourseView is to connect
the loading states to the UI by binding to the Course and Error properties of CourseService.
CourseView still does trigger startLoadCourse() – this time on CourseService instead of
CourseAPI – but, alternatively we can choose to have that automatically handled by CourseService
shortly after initialization, if we so desire.
Notice how we now match on the properties of CourseService to set the state inside the body.
1 import SwiftUI
2
3 struct CourseView: View {
4
5
// These are gone now
6
//
@State private var course: Course?
7
//
@State private var error: Error?
8
9
// We bind to a CourseService so the UI will respond to changes of
its properties
10
@Bindable private var courseService: CourseService
11
12
// We now initialize with a courseService
13
init(courseService: CourseService) {
14
self.courseService = courseService
15
}
16
17
// Inside the body, we now rely on CourseService's properties.
341
Mobile System Design
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36 }
var body: some View {
// We bind on CourseService's error and course properties to
decide which view to show.
if let error = courseService.error {
Text("Error: " + error.localizedDescription)
} else if let binding = Binding($courseService.course) {
CourseUI(course: binding)
} else {
VStack {
Text("Loading")
ProgressView()
// CourseView now calls startLoadCourse on
courseService
// as opposed to handling this itself.
}.task { await courseService.startLoadCourse() }
}
}
// We don't need to load a course, check it for errors, or check it
for updates anymore.
NOTE: Remember how we marked CourseService as @Observable? This allows us to react to its
property changes. We achieve this by marking the courseService property as @Bindable. This
ensures that SwiftUI updates the UI once some properties of CourseService are updated. The difference
between @Binding is that it is reserved for structs and enums, whereas @Bindable is reserved for
classes. If you find this confusing, you’re not alone and I don’t blame you. It’s a SwiftUI detail and not
important to the concept of this chapter.
Extracting this logic to the business domain makes CourseView more lean. CourseView becomes a
thin UI veneer over a fatter business model.
All that it now requires is a CourseService. Note that CourseView doesn’t need to handle the
changes anymore, nor does it set the state to failure when we receive an error. This is all now handled
by CourseService.
One downside of this approach is that we are introducing another new type, causing one more domain
of indirection, "just" to load and update a Course.
One could say it’s a heavyweight approach for something as simple as loading and displaying data.
Looking at CourseView, it doesn’t have a lot of added value anymore. But, it has some value, namely
to glue course and its loading states from data to UI.
NOTE: With a little imagination, it would be doable to turn CourseView into a loading state view for
many more types that require it.
But, we will receive a lot of benefits. The major benefit is that this feature is more usable without
342
Mobile System Design
UI, which aids in its portability. Perhaps the biggest benefit of all is that it’s less muddy where the
source-of-truth lives. The UI domain now purely focuses on bindings.
To get a deeper understanding, let’s go over some benefits of the portable approach.
15.3 Verifying our new implementation
It may seem overkill to introduce CourseService. But, to show you the benefits of this "simple" idea,
let’s look at how we can use more of the Course feature in various ways.
15.3.1 Detaching a feature from a screen
One great advantage is that loading and updating a Course is now detached from the CourseView
screen.
A user can load and update a Course, navigate away from the screen, and the synchronization with the
backend servers will continue in the background. A user doesn’t have to fear the operation is canceled
when navigating away!
Even better; A user could reopen the CourseView screen with the same Course – essentially the app
would reattach CourseService to CourseView – and the user would instantly see the proper, latest,
state. CourseView wouldn’t need to trigger startLoadCourse() on CourseService every time it
opens.
But more importantly: The feature now always works in the background, it doesn’t need UI. This affects
how we think about a feature.
For instance, if a user navigates away after updating a Course, the app could inform a user about status
updates via notifications or toasts. Whereas before leaving CourseView means we would also destroy
the CourseService instance, and the user wouldn’t be able to receive any sync updates outside of
CourseView.
343
Mobile System Design
Detaching the feature from the UI is now possible, because we ensure that CourseView is not the
source-of-truth anymore. CourseService lives on outside of CourseView.
NOTE: This scenario works well for a single course. However, if we were to update multiple courses
simultaneously, we would have to figure out a different solution. Perhaps we only want to show toasts for
the latest Course updates, or group the specific updates together. Either way, the feature lives outside
of the UI.
15.3.2 Unit-tests instead of manually checking UI
Before, we would have to rely on manual checks – such as relying on a QA engineer or using a feature
ourselves before merging code – to verify that mutating Course on a device would sync with the
backend.
NOTE: Alternatively, we could rely on integration tests or end-to-end tests to verify this scenario works as
intended; Then we would write UI Tests to run actions against a backend server. This is a valid approach,
but requires a significant time investment to automate.
But thanks to our relatively minor change, it’s now possible to unit-test that once we mutate a Course,
it triggers a call to keep the backend in sync. This requires less effort to write, and on top of that, unit
tests are generally more reliable and are faster to run compared to UI Tests.
For example. Below, in a unit-test, we mutate the course property on CourseService. Then, we
verify the feature triggers network requests to ensure it keeps the backend-servers up to date.
We achieve this by collecting any requests that CourseService has sent to a MockNetwork implementation. Then, we verify the request values.
344
Mobile System Design
NOTE: We first set up the entire structure by initializing a CourseService and its underlying dependencies, using a MockNetwork instance. We use a MockNetwork so that we can inspect any network
requests.
1 final class CourseServiceTests: XCTestCase {
2
3
// ... snip
4
5
// Helper method to initialize the dependencies, using a
MockNetwork instance.
6
private func makeTestableCourseService() -> (MockNetwork,
CourseService) {
7
// We'll use MockNetwork to verify the calls that are fired off
.
8
let mockNetwork = MockNetwork()
9
// The rest of the code uses regular, non-mocked instances.
10
let api = API(network: mockNetwork)
11
let courseAPI = CourseAPI(tutorAPI: TutorAPI(api: api),
12
todoList: TODOList(api: api),
13
calendar: Calendar(api: api))
14
15
let courseService = CourseService(ID: UUID(), courseAPI:
courseAPI)
16
17
// We return both the mockNetwork and courseService instance.
18
return (mockNetwork, courseService)
19
}
20
21
// In this test we'll ensure that CourseService fires off a network
request.
22
// We mark this test as async since we use async calls inside of it
.
23
// We also mark it as throws, because this test can throw on
failure.
24
func testBackendIsUpdatedOnCourseMutation() async throws {
25
// We create and receive a mockNetwork and a CourseService
26
// We can set two properties simultaenously by using a tulip.
27
// A tulip is similar to an unnamed struct.
28
let (mockNetwork, courseService) = makeTestableCourseService()
29
30
// We load a course
31
await courseService.startLoadCourse()
32
33
// We unwrap the course using XCTUnwrap. XCTUnwrap assumes
course is not nil, otherwise the test will fail.
34
var course = try XCTUnwrap(courseService.course)
35
36
// Next, we modify a course on CourseService
37
course.schedule = [TODOItem(title: "Custom todo item",
recurringInDays: 3)]
345
Mobile System Design
38
39
40
41
42
// We give courseService the updated course.
courseService.course = course
// Now we ensure CourseService triggered a POST network-request
somehow
// We achieve this by inspecting the network activity
let didReceiveRequest = mockNetwork
.receivedRequests
.contains(where: { request in
request.httpMethod == "POST" &&
request.url?.absoluteString == "https://
www.myawesometutorapp.com/courses/
update" &&
request.body["ID"] == course.id.uuidString
})
43
44
45
46
47
48
49
50
51
52
53
54
55 }
}
// We verify that we received the proper request
XCTAssert(didReceiveRequest)
NOTE: We use contains(where:) to see if any request in the list matches the patterns we match on.
This is a minor example, but with some effort we can unit test much more. Such as completely checking
if all data that passed is correct, or that CourseService does not do anything if we update the course
with the same state. We can also verify any queuing or batching logic, to ensure the app doesn’t
hammer the server on multiple updates.
But the idea is, we can now access more of the Course feature in unit-tests. It proves we don’t depend
on UI for it to work. This approach ensures we don’t rely on manual checks to verify the feature either.
NOTE: This doesn’t replace a complete end-to-end test, because we aren’t verifying that the backendservers have the latest data. But at least we can verify that the feature works as intended on the app
itself.
15.3.3 Supporting other platforms and patterns
Another major benefit is that the Course feature is not strongly tied to a specific platform, paradigm,
or UI pattern anymore.
For instance, let’s say our team moves back to UIKit because of some SwiftUI shortcoming (hey, it
happens).
With little effort, we can have this feature work in UIKit. Before, we would have to re-implement the
fact that a Course loads and gets updated on mutation, and that it can be in an erroneous state.
346
Mobile System Design
But now, we get all that for free from CourseService. The only thing we don’t get for free is gluing
CourseService to UIKit, because that is platform-specific.
To show how this works, we’ll introduce a CourseViewController, we’ll pass a CourseService to
it. Like before in CourseView, we’ll inspect the state of CourseService, and update the view-state
accordingly. We can place this code inside a custom method we name updateView().
Since this is UIKit, we can trigger this method from UIKit’s viewDidLoad() method – which is called
when the view code is set up.
Note that we call updateView() directly first to set the initial state, which will most commonly
– but not always – be a loading state. After that, we trigger the startLoadCourse() method on
CourseService using a Task to load the latest Course.
NOTE: We need to wrap the startLoadCourse() method in a Task, because startLoadCourse()
is an async call, and viewDidLoad() is not.
Then, after CourseService finishes loading, we’ll refresh the UI by calling updateView() again, after
which we’ll either show the Course or a possible error.
1 // We are using UIKit instead of SwiftUI now
2 import UIKit
3
4 class CourseViewController: UIViewController {
5
6
// We can depend on CourseService because it lives in the model
domain
7
private let courseService: CourseService
8
9
// We can initialize this class with a CourseService
10
init(courseService: CourseService) {
11
self.courseService = courseService
12
super.init(nibName: nil, bundle: nil)
13
}
14
15
// UIKit calls this method right before a view is presented.
16
override func viewDidLoad() {
17
super.viewDidLoad()
18
19
// Call updateView() to set the initial state (probably a
loading state)
20
updateView()
21
22
Task {
23
await courseService.startLoadCourse()
24
// Call updateView() again after courseService updates.
25
updateView()
26
}
27
}
347
Mobile System Design
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60 }
// This is where the view code is updated.
private func updateView() {
// Depending on the state, we'll show the appropriate view
switch (courseService.course, courseService.error) {
case (let course?, nil):
// Course is unwrapped, the error is nil.
// This means we can show the course
showCourseUI(course)
case (nil, let error?):
// Course is nil here
// Error is unwrapped, so we show that instead.
showError(error)
default:
// If neither course are error are filled
// We can assume it's loading.
showLoadingUI()
}
}
private func showCourse(_ course: Course) {
// ... snip (not depicted)
}
private func showError(_ error: Error) {
// ... snip (not depicted)
}
private func showLoadingUI() {
// ... snip (not depicted)
}
The loading, mutation, and updating states are all handled for us already. All we have to focus on is
connecting the UI to CourseService. Then, once we mutate the course in this class, CourseService
will make sure it auto-syncs with the backend.
To keep this example small, we omitted some details, because there is a bit more to it; Such as updating
the UI once CourseService updates outside of this view, and updating the Course property on
CourseService after a user updates the state.
Since we don’t get SwiftUI bindings for free, we would have to think of some alternatives. One solution
is to add a closure to CourseService which triggers once Course is updated.
For example, we could offer a didUpdate closure on CourseService, then its API might look as
follows: It might pass the latest course and error, which we can use or ignore. This closure would be
called once CourseService updates, then we can trigger our updateView() method.
348
Mobile System Design
1
2
3
4
5
6
7
8
// This closure is triggered once courseService is updated.
self.courseService.didUpdate { [weak self] course, error in
self?.updateView()
}
// Somewhere else in the code, this would trigger the didUpdate closure
self.courseService.course = updatedCourse
NOTE: We use [weak self] to ensure the closure does not capture CourseViewController. This is
to avoid a retain-cycle.
But, how we glue our code to UI isn’t too important. It differs per implementation. For instance, if this
were a reactive codebase, we might connect to some publishers instead.
It’s good to know that every platform or pattern has their subtle differences of gluing code together. As
long as we ensure a feature does most of the work, then we only need to worry about the glue in the UI
domain.
349
Mobile System Design
15.4 Conclusion
Moving code from the UI domain to the model domain is a simple concept. But it easily be forgotten if
we’re not mindful about it.
Also it’s usually quicker to get up and running by placing more code in the UI. But, as we have covered
in this chapter, there are a ton of benefits by being mindful of where you place business logic.
These are just a few examples of the benefits we gain. But with a little imagination we can support
more, such as background syncing. Or ensuring the app is loading data triggered by a push notification.
This ensures the app is already "pre-warmed" before the user opens it.
Then, if we were to extract this feature to its own module or package, we can support even more; Such
as a Mac app, a headless client, wearables such as a watch or VR headsets, and so on.
If we were a vendor, we could deliver more of a Course feature without enforcing clients to use a
(specific) UI framework. They can grab a CourseService and use it in any target.
We’ve also seen how a feature can work separately from UI; This affects how we think about a feature,
since it can continue to operate in the background. This might mean we have to support some sort of
notification or toast system, or something else entirely.
Next time you see a loadFeature() implementation in a UI domain, take a closer look. You may
realise that perhaps that entire function’s body should move to the model domain.
As you can tell, I’ve been really trying to explain the importance of making a feature portable and
self-sufficient. This is going to help with small features and entire modules, as we’ll cover further in the
book.
350
Mobile System Design
15.5 What we covered
In this chapter, we covered:
• A portable feature works across targets, without relying (too much) on a specific UI pattern, UI
architecture, or paradigm.
• When a view handles a loadData call, it can be an indicator that the source-of-truth for data
and state lives in the UI domain.
• By moving more logic to the model domain, we can make a feature more portable.
– This means that the model domain will become the source-of-truth for data and state.
– As a result, a view can focus on gluing the data and state to UI.
• Once a feature can work without UI, it opens up a lot of possibilities, such as:
– We can unit tests that a feature works, as opposed to use UI tests or manual checks.
– We can run a feature on various platforms and targets.
– We can more easily move to a different UI pattern, architecture, or paradigm.
– A feature can more easily run in the background.
• A portable feature can be detached from a screen. Then feature can keep working in the background.
– This allows us to keep a feature alive after a user navigates away from a screen.
• A backgrounded feature forces us to think differently about its workings.
– Actions may happen outside of UI.
– A user will need to be informed outside of a screen. For instance, a feature may send
notifications or toasts if there is no UI present.
351
Download