Eric Vennaro - IOS Development at Scale - App Architecture and Design Patterns For Mobile Engineers-Apress (2023)
Eric Vennaro - IOS Development at Scale - App Architecture and Design Patterns For Mobile Engineers-Apress (2023)
at Scale
App Architecture and Design Patterns
for Mobile Engineers
—
Eric Vennaro
iOS Development
at Scale
App Architecture and Design
Patterns for Mobile Engineers
Eric Vennaro
iOS Development at Scale: App Architecture and Design Patterns for Mobile
Engineers
Eric Vennaro
San Francisco, CA, USA
Introduction�������������������������������������������������������������������������������������xxiii
v
Table of Contents
vi
Table of Contents
vii
Table of Contents
viii
Table of Contents
Design Patterns�������������������������������������������������������������������������������������������������182
Overarching Themes������������������������������������������������������������������������������������182
Delegate Pattern������������������������������������������������������������������������������������������184
Facade Pattern (Structural)�������������������������������������������������������������������������188
The Builder Pattern��������������������������������������������������������������������������������������192
The Factory Pattern�������������������������������������������������������������������������������������201
Singleton�����������������������������������������������������������������������������������������������������208
Dependency Injection (DI)����������������������������������������������������������������������������212
Coordinators������������������������������������������������������������������������������������������������217
Observer������������������������������������������������������������������������������������������������������230
Summary����������������������������������������������������������������������������������������������������������238
Two Key Takeaways from This Chapter�������������������������������������������������������239
Further Learning������������������������������������������������������������������������������������������239
ix
Table of Contents
Chapter 9: VIPER�������������������������������������������������������������������������������299
Overview�����������������������������������������������������������������������������������������������������������299
This Chapter Includes����������������������������������������������������������������������������������300
A Detailed Look at VIPER�����������������������������������������������������������������������������������300
VIPER Components��������������������������������������������������������������������������������������301
Component Interactions������������������������������������������������������������������������������302
Practical Example����������������������������������������������������������������������������������������304
Discussion���������������������������������������������������������������������������������������������������321
Summary����������������������������������������������������������������������������������������������������������325
Three Key Takeaways from This Chapter�����������������������������������������������������326
Further Learning������������������������������������������������������������������������������������������326
x
Table of Contents
xi
Table of Contents
xii
Table of Contents
xiii
Table of Contents
Network-Related Metrics����������������������������������������������������������������������������465
Engagement Metrics�����������������������������������������������������������������������������������467
A Brief Practical Example����������������������������������������������������������������������������������468
Summary����������������������������������������������������������������������������������������������������������471
Four Key Takeaways from This Chapter������������������������������������������������������472
Further Learning������������������������������������������������������������������������������������������472
xiv
Table of Contents
xv
Table of Contents
xvi
Table of Contents
Index�������������������������������������������������������������������������������������������������637
xvii
About the Author
Eric Vennaro is a tech lead at Meta, where
he has a track record of delivering high-
impact, technically complex projects across
mobile, web, and back-end infrastructure.
He is interested in applied machine learning
and privacy, especially the intersection of
improving privacy using machine learning–
backed integrity tooling. While working on the
iOS platform and recruiting new iOS engineers, Eric noticed a gap in the
existing literature for mobile engineering best practices and architectural
principles at scale. To address this gap, he decided to write this book using
his experience in leading mobile projects. Before working at Meta, Eric
founded his own company and worked at Stitch Fix during its explosive
growth phase and subsequent IPO.
xix
About the Technical Reviewers
Mezgani Ali is a doctor in God sciences
and religious studies and a Ph.D. student
in transmissions, telecommunications, IoT,
and artificial intelligence (National Institute
of Posts and Telecommunications in Rabat).
He likes technology, reading, and his little
daughter Ghita. Mezgani’s first program was a
horoscope in BASIC in 1993, and he has done a
lot of work on the infrastructure side in system
engineering, software engineering, managed
networks, and security.
Mezgani has worked for NIC France, Capgemini, HP, and Orange,
where he was part of the Site Reliability Engineer’s (SRE) team. He is also
the creator of the functional and imperative programming language PASP.
Mezgani is the founder of and researcher at Native LABS, Inc., which
manufactures next-generation infrastructures with a great interest in
Internet protocols and security appliances.
xxi
Introduction
This book will teach you how to build mobile applications that will
scale for millions of users while growing your career to the staff and
principal levels.
The book is structured to mirror an engineer’s career path and maps
the career stages to the tools needed for success at each one. We start with
the basics of engineering covered with a Swift language focus; however,
most fundamentals are applicable beyond the Swift programming
language. This is by design because, over time, frameworks and languages
will come and go (SwiftUI, obj-c), but the fundamental concepts
underlying them will not. Understanding these basic concepts allows you
to apply them to changing environments and efficiently learn new tools –
an even more critical skill at scale because many large companies write
custom implementations. Understanding the fundamentals marks the first
stage of a software engineer’s career and the first part of this book.
The book’s second part will discuss building better applications
using design patterns and application architecture principles. Mastering
iOS application architecture and fundamental design patterns is critical
for reaching the senior engineer level. At this stage, you are most likely
capable of managing your own work autonomously and can help
junior engineers ramp up on the fundamental aspects of application
development. This is where most books stop, but this is only the career
midpoint, and technical skills alone will not take you beyond senior
engineer.
xxiii
Introduction
xxiv
Introduction
xxv
PART I
Swift Familiarization
This chapter aims to provide the reader with the tools and knowledge to
architect application components and serve as a familiarization for future
sections that reference the types outlined here. We will go over structs,
classes, protocols, and generics. This chapter is not a detailed overview of
every Swift type and language feature. For that, please refer to the official
Apple documentation.
enum DogBreed {
case other
case germanShepard
case bizon
case husky
}
class Dog {
// parameters - part of the state.
// the dog's name
4
Chapter 1 Swift Familiarization
// initializer
init(name: String,
breed: DogBreed,
lastFed: Date? ) {
self.name = name
self.breed = breed
self.lastFed = lastFed
}
5
Chapter 1 Swift Familiarization
struct DogStruct {
var name: String
var breed: DogBreed
var lastFed: Date?
init(name: String,
breed: DogBreed,
lastFed: Date? ) {
self.name = name
self.breed = breed
self.lastFed = lastFed
}
6
Chapter 1 Swift Familiarization
default:
print("barking moderate")
}
}
}
From the preceding code samples, it would appear that classes and
structs are the same; however, they have many differences that stem from
how they are managed in memory. Classes are reference types, while
structs are value types. For value types, such as structs, each instance
keeps a unique copy of its own data. Other value types include enums,
arrays, strings, dictionaries, and tuples. In contrast to value types,
reference types share a single copy of the data and reference that copy via
a pointer. The only reference type in Swift is the class; however, everything
inheriting from NSObject is a reference type, meaning that iOS engineers
will interact with both value and reference types, and being familiar with
them is a must.
In addition to the lower-level differences between value and reference
types that are discussed in the next chapter, the two tangible changes at the
application level are as follows:
7
Chapter 1 Swift Familiarization
When utilizing a class, it was possible to change the dog’s name for the
initial variable; however, with the struct, it was not. In practice, this extra
layer of protection protects against unintended mutations. The protection
from unintentional modification is especially helpful in ensuring thread
safety. Even so, value types are not entirely safe because it is possible to
add a reference type inside a value type. When a reference type is added
inside a value type such as adding a class instance to an array (a value
type), the class (reference type) is modifiable. The following example
explores how this could happen:
8
Chapter 1 Swift Familiarization
9
Chapter 1 Swift Familiarization
10
Chapter 1 Swift Familiarization
11
Chapter 1 Swift Familiarization
class Animal {
// even wild animals have names (you just don't know them)
var name: String
var breed: DogBreed
// changing to lastEaten since wild animals don't get fed
var lastEaten: Date?
init(name: String,
breed: DogBreed,
lastEaten: Date?) {
self.name = name
self.breed = breed
self.lastEaten = lastEaten
}
12
Chapter 1 Swift Familiarization
func eat() {
self.lastEaten = Date()
}
The preceding inheritance chain is fine and dandy, but what happens
when we have wolves? And lions (and tigers and bears – oh my)? Should
these inherit directly from the animal base class? Or should they have
their own superclasses to better model the traits of their specific scientific
classification suborders? For example, it may be necessary to create separate
superclasses for the mammalian suborders Caniformia and Feliformia with
more specific shared attributes for those suborders. Establishing separate
intermediate superclasses seems like an easy way to better model animals
and still reuse code; however, this approach tends to create long, tightly
coupled inheritance chains. Over time these chains become brittle and
difficult to maintain (especially as the development team changes).
13
Chapter 1 Swift Familiarization
For classes in Swift, inheritance allows for code reuse and the benefits
of dynamic polymorphism through overloading and overriding functions.
Luckily for value types, Swift provides protocols as a way to achieve static
polymorphism.
Note Classes can also use protocols; however, structs cannot use
inheritance (although default implementations on protocols provide
similar benefits).
14
Chapter 1 Swift Familiarization
Protocols
A protocol is another way for our Swift code to leverage polymorphism.
Much like an interface, a protocol defines a set of methods, properties,
and other requirements a class, structure, or enumeration can adopt. To
implement a protocol, the struct, or other types, must provide a concrete
implementation of the requirements defined by the protocol. A type
that satisfies a protocol is said to conform to the protocol, and types can
conform to many protocols promoting the idea of composing objects
based on behaviors.
Additionally, protocols are extensible, which allows them to provide a
default implementation for types that extend them (by defining behavior
on the protocol itself ). Protocols allow extensions to contain methods,
initializers, subscripts, and computed property implementations.
A well-designed protocol suits a particular task or piece of
functionality, and types can conform to multiple protocols, promoting
composition. With that in mind, let us review our previous example, but
this time with protocols.
Since a type can conform to multiple protocols, our example
decomposes individual characteristics of mammals into separate protocols
avoiding complex hierarchical chains associated with inheritance.
15
Chapter 1 Swift Familiarization
protocol ProduceSoundProto {
func makeNoise();
}
// Now we can easily create dogs, wolves, and any number
of animals
struct Dog_ProtoExample: AnimalProto, FeedsProto,
ProduceSoundProto {
var lastFed: Date?
var name: String
var breed: DogBreed
func makeNoise() {
print("barking...")
}
}
16
Chapter 1 Swift Familiarization
func makeNoise() {
print("roar...")
}
17
Chapter 1 Swift Familiarization
18
Chapter 1 Swift Familiarization
Generics in Action
In addition to structs, classes, inheritance, and protocols, there is one more
important concept that is referenced throughout this book: generics.
In its most simplistic form, generic programming is a style of
programming where the programmer creates functions and types
containing input and/or output parameters without specifying the
exact types. The concrete types of input and output parameters are left
unspecified and only instantiated when needed.
The usage of generic programming allows for flexible, reusable code
that works for various parameter types. Supporting multiple parameter
types enables programmers to define standard functions that take different
types as inputs (as long as those types meet specific requirements defined
by the program). Overall, generic programming avoids duplication and aids
programmers in building better abstractions. Most programming languages
support generics by providing language constructs for programmers to utilize.
Software engineers rely on these built-in language constructs to create generic
programs; here, we will discuss how Swift implements these constructs.
Generic Functions
To better understand generics in action, let’s start with an example of a
basic generic function:
19
Chapter 1 Swift Familiarization
Generic Types
In addition to generic functions, Swift allows for creating generic types.
These classes, enums, and structs are customized to work with any type.
An excellent example of generics in action is the Swift collection types
(array, dictionary). Creating generic types is quite similar to creating a
generic function, except that the placeholder type is defined on the type
itself. As an example, let’s look at creating a generic Queue in Swift:
20
Chapter 1 Swift Familiarization
In the preceding Queue, the placeholder type is defined with the struct
and used throughout the type. In this way, the Queue works for any type.
Without this behavior, programmers would need a separate Queue struct
for each type.
Generics are also utilized in protocols to create richer type
experiences. To express generic types in protocols, Swift utilizes
typealiases and associatedtypes. An associated type is used in the protocol,
while the typealias specifies the type used in the protocol’s concrete
implementation. In other languages, typealiases are primarily syntactic
sugar to wrap a long, more complex type in an easier-to-understand way;
however, in Swift, typealiases also serve as a semantic requirement on
types adopting a protocol with an associated type.
Next, let’s extend our animal example from earlier to include a protocol
for all animals. This new protocol also specifies that each animal eats a
specific food, where a food is a separate struct defined by the concrete
implementation of the animal protocol.
21
Chapter 1 Swift Familiarization
protocol Animal_Proto { }
22
Chapter 1 Swift Familiarization
23
Chapter 1 Swift Familiarization
func eat(food: T) {
_eat(food)
}
}
In previous versions of Swift, we could then box our types using the
preceding wrapper. However, Swift now requires that at initialization,
Antelope conforms to Animal_Proto.
This makes sense since the wrapper object is merely hiding the
underlying types. However, it is still a good example of generics in
protocols. More practically, try altering the code architecture to avoid the
issue. For example:
enum Food {
case grass
case meat
}
protocol Animal_Proto {
func eat(food: Food) -> ()
}
24
Chapter 1 Swift Familiarization
Summary
In this chapter, we reviewed some basic types and fundamental Swift
language concepts such as inheritance, polymorphism, and generics.
25
Chapter 1 Swift Familiarization
Further Learning
1. Swift language guide:
a. https://docs.swift.org/swift-book/LanguageGuide/
TheBasics.html
a. https://developer.apple.com/videos/play/
wwdc2015/408/
26
CHAPTER 2
Memory Management
Understanding memory management is key to developing programs that
perform correctly and efficiently. This section dives into how computer
memory is allocated and released for Swift programs, how the Swift
memory model is structured, and best practices for memory management.
The overall quality of a software application is primarily judged by
its performance and reliability, which are highly correlated with good
memory management. Memory management becomes crucial
in large-scale multinational applications where older devices are
prevalent.
The Stack
At its core, a stack is a simple last-in, first-out (LIFO) data structure. Stacks
must support at least two operations: push and pop. Inserting or removing
from the middle of a stack is not allowed. The data items pushed on the
runtime stack may be any size. Stacks are somewhat rigid in that they only
allow access at the top. Nevertheless, this rigidity makes stacks easy to
implement with efficient push and pop operations.
Inside a program, when calling a function, all local instances in that
function are pushed onto the current stack. Furthermore, all instances are
removed from the stack once the function has returned.
29
Chapter 2 Memory Management
Figure 2-2. Stack memory; this stack grows downward. Push copies
data to stack and moves pointer down; pop copies data from the stack
and moves pointer up
Note LIFO is an abbreviation for last in, first out. This is when the
first element is processed last, and the last element is processed
first. Conversely to LIFO, we can process data in a first in, first out
manner commonly abbreviated as FIFO. With FIFO, the first element
is processed first, and the newest element is processed last.
The Heap
In contrast, the heap is more flexible than the stack. The stack only allows
allocation and deallocation at the top, while the heap programs can
allocate or deallocate memory anywhere. The heap allocates memory by
finding and returning the first memory block large enough to satisfy the
30
Chapter 2 Memory Management
Figure 2-3. The heap. Currently stored on the heap are the string
pool and an object
• Slower access.
1
https://developer.apple.com/documentation/swift/unsafepointer
32
Chapter 2 Memory Management
This check reports overflow if the accessed memory is beyond the end of
the buffer and underflow if the accessed memory is before the beginning
of a buffer. Additionally, Xcode sanitizes heap and stack buffers, as well as
global variables.2
struct Dog {
var age: Int
func bark() {
print("barking")
}
}
2
https://developer.apple.com/documentation/xcode/overflow-and-
underflow-of-buffers
33
Chapter 2 Memory Management
34
Chapter 2 Memory Management
class Dog {
var age: Int
func bark() {
print("barking")
}
}
// creating the instance
let dog1 = Dog(age: 2)
let dog2 = dog1
For the Dog class, memory is first allocated on the stack. This memory
references memory allocated on the heap (a pointer from the stack to the
heap). The heap allocation occurs on initialization (the heap is searched for
an appropriate block of memory), and the instance is copied so both dog1
and dog2 point to the same memory on the heap, so a change to dog1 will
affect dog2 – reference semantics at work. Figure 2-5 outlines the interaction.
35
Chapter 2 Memory Management
Note ARC only frees up memory for objects when there are zero
strong references.
36
Chapter 2 Memory Management
Memory leaks and dangling pointers lead to app crashes and poor user
experiences.
init(name: String) {
self.name = name
print("\(name) is being initialized")
}
37
Chapter 2 Memory Management
deinit {
print("\(name) is being deinitialized")
}
}
class Engine {
let type: String
init(type: String) {
self.type = type
}
var car: Car?
deinit {
print("Engine \(type) is being deinitialized")
}
}
38
Chapter 2 Memory Management
herby?.engine = inlineSix
inlineSix?.car = herby
herby = nil
inlineSix = nil
//Note neither deinitializer is called
Figure 2-6. The two initialized variables; the solid line denotes the
strong reference cycle between the engine in the car
As illustrated in Figure 2-7, ARC still maintains a reference for the car
and the engine. Neither is deallocated even after assigning each variable to
nil. To fix this, use a weak reference (visually depicted in Figure 2-8).
39
Chapter 2 Memory Management
class Car_V2 {
let name: String
var engine: Engine_V2?
init(name: String) {
self.name = name
print("\(name) is being initialized")
}
deinit {
print("\(name) is being deinitialized")
}
}
class Engine_V2 {
let type: String
init(type: String) {
self.type = type
}
weak var car: Car_V2?
deinit {
print("Engine \(type) is being deinitialized")
}
}
ford?.engine = inlineFour
inlineFour?.car = ford
40
Chapter 2 Memory Management
ford = nil
inlineFour = nil
// Prints:
// "Engine Inline Four Cylinder is being deinitialized"
// "Ford is being deinitialized"
Figure 2-7. A strong reference cycle still exists between the engine and
the car even after the variables are deallocated
Figure 2-8. The two initialized variables and the references between
them. The weak reference is denoted by the dotted line
41
Chapter 2 Memory Management
Pretty cool, right? Now we can avoid memory leaks; however, we could
slightly improve this. Since we know a car must always have an engine, we
could also set the property as an unowned reference. Using an unowned
reference will remove the need to unwrap the optional and mirrors our
hypothetical product requirement of a car always having an engine. When
using an unowned reference, one must be careful to fully understand the
software requirements since accessing an unowned reference when it is
nil causes a fatal program error. When in doubt, it is safest to use a weak
reference.
class Car_V3 {
let name: String
var engine: Engine_V3?
init(name: String) {
self.name = name
print("\(name) is being initialized")
}
deinit {
print("\(name) is being deinitialized")
}
}
class Engine_V3 {
let type: String
unowned let car: Car_V3
init(type: String, car: Car_V3) {
self.type = type
self.car = car
}
deinit {
print("Engine \(type) is being deinitialized")
}
}
42
Chapter 2 Memory Management
func willLoadData() {
// do something
}
}
protocol ViewModelDelegate {
func willLoadData()
}
func bootstrap() {
delegate?.willLoadData()
}
}
43
Chapter 2 Memory Management
Another common use case for this is within capture lists for closures.
44
Chapter 2 Memory Management
Method Dispatch
The last area of the Swift memory model we will touch on here is method
dispatch. Swift needs to execute the correct method implementation
when called at runtime. The way that the programming language, Swift,
ascertains the correct method to call occurs at either compile time
(statically) or runtime (dynamically). Objective-C heavily utilized runtime
dispatch, giving the language immense flexibility; however, Swift leans
heavily on static dispatch, allowing the compiler to optimize the code.
With runtime dispatch, dispatches cannot be determined at compile
time and are looked up at runtime blocking compile-time visibility and
optimizations.
One such compiler optimization is inlining. Inlining is when the
compiler replaces method dispatches with the actual implementation
of the function, removing the overhead of static dispatch and associated
setup and teardown of the call stack. This optimization becomes more of
a performance enhancement when an entire chain of dispatches can be
inlined.
45
Chapter 2 Memory Management
Dynamic Dispatch
In contrast to static dispatch, dynamic dispatch provides a great deal of
flexibility. It provides polymorphism and inheritance for reference types
and is implemented via a virtual-table (V-table) lookup. A V-table lookup
is created at compile time during SIL (Swift Intermediate Language)
generation, which specifies the actual implementation of the method
that should be called at runtime. During runtime, this lookup table is
held as an array of addresses to the actual location in memory where the
implementation resides (virtual pointer). V-tables help inherited classes to
generate the correct calls to overridden and non-overridden methods. If
the class is marked as final, Swift also provides an optimization to remove
the dynamic dispatches for the class and statically dispatch those methods.
To better understand V-tables, it is helpful to visualize what is going on.
In Figure 2-9, we have an array of Animal objects. The array has no specific
type information, only that each entry points to an object of type Animal.
Dogs, cats, and tigers all fit into this category because they are derived
from the Animal base class and can respond to the same messages. With
dynamic dispatch, the compiler does not know that the elements of the
array are anything more than Animal objects. When a function is called
46
Chapter 2 Memory Management
through the base class address (our Animal array), the compiler generates
a lookup through the type to the virtual method table, which contains the
virtual pointer to the proper method implementation.
47
Chapter 2 Memory Management
48
Chapter 2 Memory Management
49
Chapter 2 Memory Management
50
Chapter 2 Memory Management
Figure 2-13. The PWT for two structs conforming to the animal
protocol
The PWT does not provide a uniform memory size for each type; for
that, we need the Existential Container. Using the Existential Container,
which refers to a specific PWT, we can add our value types to an array and
then reference the concrete implementations through the PWTs. Figure 2-14
shows how this storage mechanism would function for an array of Animal
objects.
51
Chapter 2 Memory Management
Figure 2-15. Linking between the VWT, Existential Container, and PWT
52
Chapter 2 Memory Management
In the figure, the VWT table tracks the allocation of the value types and
tracks the pointer to the Existential Container. When allocate is called on
the type, the VWT allocates memory on the heap and stores a pointer to
that memory in the value buffer of the Existential Container.
Suppose the value type is copied in code. In that case, the copy
function is called to copy the value from the source of the assignment
(where the local variable was initialized) to the Existential Container’s
value buffer. Note that since the type is large, it is not stored directly in the
value buffer.
When the object is deallocated, the VWT will call the destruct entry to
decrement any reference counts if they exist. Lastly, the memory on the
heap is deallocated for the value. If any references exist, the deallocate
operation also removes any references in the Existential Container.
53
Chapter 2 Memory Management
class CatStorage {
// implement all attributes of an Animal
}
54
Chapter 2 Memory Management
Handling Generics
Generics in Swift is a form of static parametric polymorphism, and Swift
Generics leverages this to optimize code at compile time further. Swift
creates a type-specific version of the generic function for each type used
in code. This allows for the compiler to inline the method calls and only
create specific functions for types used in code. In addition, to optimize the
code via inlining, the compiler can also utilize whole module optimization
to optimize the code based on what types are used in the module. In
practice, using generic types (where applicable) allows for additional
performance enhancements and code architecture improvements.
55
Chapter 2 Memory Management
Large Types
56
Chapter 2 Memory Management
Bug Fixing
Engineers are not only tasked with writing quality code but also with fixing
bugs. Especially as a mobile engineer, some of these bugs will be related to
mismanaged memory. For debugging these issues, it is essential to understand
them and what tools Xcode and the iOS ecosystem provide to help fix them.
1. Instruments: Do not forget to profile on an iOS
device. The runtime architecture of the simulator is
different and may not be helpful when debugging.
2. Memory graph: An excellent tool to look through
features.
57
Chapter 2 Memory Management
For example, let’s say your notification service extension is running out
of memory and causing crashes for certain users who are receiving a large
volume of background push notifications. Does your team have the tooling
to detect this from bug reports? Are there detailed stack traces? And once
detected, do you have the tools to replicate the bug and fix it? We will talk
more about this in Part 3 of this book.
Summary
This chapter walked through the Swift memory model, how automatic
reference counting works, and how to avoid common memory
management pitfalls. While most of this chapter was more theoretical
in discussing how things work, this applies to day-to-day software
engineering as it
1. Improves debugging ability. By better
understanding how Swift functions as well as how to
best leverage existing tooling for identifying memory
issues, you are better equipped to debug issues and
understand stack traces.
2. Improves feature architecture. Understanding how
to fit your feature architecture to best utilize the
systems underlying memory management system
helps develop performant features and narrow
the design space. This generally assists in building
things right the first time, speeding up long-term
development, and reducing bugs.
3. Depending on your specific team, this may directly
impact your work as a performance reliability
engineer or potentially if you work on a lower-level
mobile library where performance optimizations are
crucial.
58
Chapter 2 Memory Management
Further Learning
1. Performance tuning:
a. https://developer.apple.com/library/archive/
documentation/Performance/Conceptual/
PerformanceOverview/Introduction/Introduction.
html#//apple_ref/doc/uid/TP40001410
a. https://help.apple.com/instruments/mac/current/
59
CHAPTER 3
iOS Persistence
Options
Overview
Persistent storage is a crucial component of any software system;
sometimes, iOS applications use only the server as the persistent store;
however, more complex apps require persistent in-app storage. Persisting
data between application cold starts on the device is essential for
Picking the correct storage format is critical for security and providing
a consistent, accurate user experience. Determining the proper storage
system requires critical thinking and understanding of the underlying
storage implementation because the underlying storage implementation
will largely dictate the level of persistence, performance, and security.
2. NSUserDefaults
3. The keychain
4. Core Data
5. SQLite
62
Chapter 3 iOS Persistence Options
63
Chapter 3 iOS Persistence Options
64
Chapter 3 iOS Persistence Options
Some of the concerns related to writing directly to the file system are
ameliorated when utilizing one of the higher-level frameworks discussed
in the rest of the chapter.
NSUserDefaults
NSUserDefaults is an Apple-provided interface for interacting with the
underlying user defaults system. The defaults “database” is a property-
list-backed file store intended to store application-level preference data.
A property list (plist) is an XML file. At runtime, the UserDefaults class
keeps the contents of the property list in memory to improve performance,
and changes are made synchronously within your application’s process.
NSUserDefaults is intended for use with data that is not scoped to a
specific user inside the context of the application because NSUserDefaults
does not consider your application’s user model.
65
Chapter 3 iOS Persistence Options
NSArgument Volatile
Application Persistent
NSGlobal Persistent
Languages Volatile
NSRegistration Volatile
1
https://developer.apple.com/forums/thread/15685
2
https://openradar.appspot.com/16761393
66
Chapter 3 iOS Persistence Options
Now we can run our application in the simulator and view the
underlying plist in the finder tool. To view the plist file, we can output the
file path to the console like so:
print(NSHomeDirectory())
/Users/myMac/Library/Developer/CoreSimulator/Devices/0F7B40
DB-67ED-43DD-B387-CD4E30FD7B45/data/Containers/Data/Application
/A5EB65F7-6192-4F7A-8A5F-620CB137DD96
67
Chapter 3 iOS Persistence Options
Opening the plist (Figure 3-2) displays the values in plain text,
presenting a security flaw; for example, if the plist included a user setting
for enabling paid features or an authentication token, the values could be
viewed and easily modified, potentially for nefarious means.
68
Chapter 3 iOS Persistence Options
Keychain
The keychain services API provides a mechanism to store small amounts
of data, such as an authentication token, in an encrypted database. The
keychain solves the problem presented by NSUserDefaults storing data in
plain text. The keychain API is a bit low level and older, which can require
some boilerplate code, especially with Swift. To avoid this, most companies
utilize keychain wrappers, either built in-house or by using a third-party
library.
69
Chapter 3 iOS Persistence Options
Core Data
The Core Data framework is similar to an object-relational mapper (ORM).
Core Data is an object graph management framework. It maintains a graph
of object instances, allowing an application to work with a graph that does
not fit entirely into memory by faulting objects in and out of memory.
Core Data also manages constraints on properties and relationships and
maintains referential integrity. Core Data is thus an ideal framework for
building the “model” component of a small MVC-based iOS application
(support also exists for Swift UI). IOS Core Data supports the following
storage formats as an underlying persistence layer:
1. SQLite
2. In Memory
3. Binary
70
Chapter 3 iOS Persistence Options
Managed Objects
Core Data provides an NSManagedObject class that allows the user to define
Core Data–backed properties to support populating the model objects
from the persistent store. The NSManagedObject class serves as the base
class for all Core Data–backed entities. The managed object is associated
with NSEntityDescription, which provides metadata related to the object,
such as the name of the entity that the object represents and the names of
its attributes and relationships. A managed object is also associated with a
managed object context that tracks changes to the object graph. Figure 3-3
outlines this interaction with SQLite as the underlying data store.
NSManagedObjectContext
The NSManagedObjectContext encapsulates model objects and sends a
notification when the managed object has changed (when an operation
such as a create, read, update, or delete occurs) and is used to manipulate
71
Chapter 3 iOS Persistence Options
NSPersistentStore
The NSPersistentStore manages the actual persistent operations, that is,
where Core Data reads and writes to the file system. The persistent store
is connected to the NSPersistentCoordinator. Each persistent store has its
own characteristics; it can be read-only, stored as binary or SQLite, or in
memory. If additional flexibility is required, it is possible to store different
parts of your model in different persistent stores.
NSPersistentCoordinator
The NSPersistentCoordinator sits between the NSManagedObjectContexts
and the NSPersistentStores and associates the object graph
management portion of the stack with the persistence portion. The
NSPersistentCoordinator utilizes the facade design pattern to manage
object contexts such that a group of persistent stores appears as a single
aggregate store and maintains a reference to a managed object model
that describes the entities in the store or stores it manages. In this
way, the NSPersistentContainer encapsulates all interactions with the
persistent stores.
72
Chapter 3 iOS Persistence Options
In most cases, the persistent store coordinator has one persistent store
attached to it, and this store interacts with the file system (commonly via
SQLite). For more advanced setups, Core Data supports using multiple
stores attached to the same persistent store coordinator. Figure 3-4
outlines this interaction using the Core Data stack components.
73
Chapter 3 iOS Persistence Options
or data object layer, which begin to look similar to the services provided
by Core Data. Instead of going through all of these steps manually, Core
Data provides a well-understood out-of-the-box alternative to a custom
solution. Writing your own system is often not worth the time and effort
unless there is a clear use case and business need.
Pros of Core Data
74
Chapter 3 iOS Persistence Options
75
Chapter 3 iOS Persistence Options
What Is SQLite
SQLite is a library that provides a lightweight, fully featured, relational
database management system (RDBMS). The lite in SQLite stands for
lightweight and is related to the ease of setup, administration, and the
small number of required resources for SQLite.
SQLite transactions are fully ACID compliant (Atomic, Consistent,
Isolated, and Durable). In other words, all changes within a transaction
take place entirely or not at all, even when an unexpected situation like an
application crash, power failure, or operating system crash occurs.
76
Chapter 3 iOS Persistence Options
1. Self-contained
2. Serverless
3. Zero configuration
Serverless
Typically, a relational database management system (RDBMS) like MySQL
or PostgreSQL needs a distinct server process to run. To interact with the
database server, applications utilize interprocess communication to send
and receive requests. This approach, known as client-server architecture,
is illustrated in Figure 3-5.
77
Chapter 3 iOS Persistence Options
Self-Contained
SQLite is self-contained in that it requires minimal support from the
operating system and external libraries. SQLite is developed using ANSI
C, and if you want to create an application that uses SQLite, you just
need to drop the SQLite C files into your project and compile it. Being
self-contained makes SQLite usable in almost any environment, which is
especially useful for embedded devices like iPhones.
Zero Configuration
Because of the serverless architecture, you don’t need to “install” SQLite
before using it. No server process needs to be configured, started, and
stopped. Additionally, SQLite does not require complex configuration files.
78
Chapter 3 iOS Persistence Options
SQLite Architecture
The SQLite library contains four core components (Figure 3-7):
1. Core
2. Backend
3. SQL compiler
4. Accessories
79
Chapter 3 iOS Persistence Options
The accessories module is mainly utils and tests, so we will skip that
component and discuss the core, backend, and SQL compiler in more
detail. Let’s start with the SQL compiler, commonly known as the front
end, and walk through the components in the order that they execute for a
SQL query.
SELECT name
FROM animals
WHERE species = "dog"
80
Chapter 3 iOS Persistence Options
The code generator runs to analyze the parse tree and generates
an SQLite virtual machine bytecode representation of the initial SQL
statement. This step also includes the query planner, which algorithmically
strives to optimize the initial SQL query. There are innumerable potential
query paths for performing the SQL query for any SQL statement. These
paths will return the correct result; however, some will execute faster
than others. The query planner strives to optimize for this and pick the
fastest, most efficient algorithm for each SQL statement. To assist the
query planner, engineers can provide indexes that help the query planner
understand the organization of the data and better create the most
efficient query. Once the code generator has optimized the query, the
parsed input is passed back into the core library.
81
Chapter 3 iOS Persistence Options
SQLite Core
Back inside the SQLite core library, the command processor passes
the optimized query to the virtual machine for execution. The virtual
machine executes the optimized query if there are enough resources
(memory, CPU).
To execute the parsed query, the virtual machine starts at instruction
zero. It runs until a halt instruction, the program counter becomes one
greater than the address of the last instruction, or an execution error
occurs. During execution for the virtual machine to read, write, or modify
the underlying database, it interacts with the SQLite back end via cursors.
Each cursor is a pointer to a single table or index within the database. The
virtual machine can have zero or more cursors. Instructions in the virtual
machine can create a new cursor, read data from a cursor, advance the
cursor to the next entry in the table or index, and many other operations
outlined in the SQLite documentation.
When the virtual machine halts, all allocated memory is released, and
all open database cursors are closed. If the execution stops due to an error,
the virtual machine terminates any pending transactions and reverses
changes made to the database.
82
Chapter 3 iOS Persistence Options
and stands for the data structure used to maintain the SQLite database
on disk. The B-tree data structure provides a performant way to query
structured data. SQLite uses separate B-trees for each table and each index
in the database.
SQLite utilizes a B+ tree, a self-balancing tree with all the values at the
leaf level to provide performant data access. This is especially important
for databases since getting information requires disk access since it is
not possible for all information to be stored in memory. For illustration
purposes, let us assume
3. 4 bytes total for the two left and right pointers (two
32-bit integers).
3
What we describe as a B-tree is interchangeable for B+ tree.
83
Chapter 3 iOS Persistence Options
4
An M-ary tree is where within each level every node has either 0 or M children.
5
Mark Allen Weiss. 2012. Data Structures and Algorithm Analysis in Java. Pearson
Education.
84
Chapter 3 iOS Persistence Options
85
Chapter 3 iOS Persistence Options
Search
For searching a B-tree of order m and data d, we can define the following
algorithm:
Insert
When inserting an element into a B-tree, we must ensure
3. If the leaf is not full, insert the key into the leaf node
in increasing order.
86
Chapter 3 iOS Persistence Options
4. If the leaf is full, insert the key into the leaf node
in increasing order and balance the tree in the
following way:
87
Chapter 3 iOS Persistence Options
Delete
We can algorithmically define deletion from a B-tree as follows:
6
https://github.com/kodecocodes/swift-algorithm-club/blob/master/
B-Tree/BTree.playground/Sources/BTree.swift
88
Chapter 3 iOS Persistence Options
89
Chapter 3 iOS Persistence Options
90
Chapter 3 iOS Persistence Options
{
"medias": [{
"image_url": String
"time_posted": String
"id": String
}],
"cursor": String
}
Before reading ahead to the solution, stop and think about how you
would architect a solution. What trade-offs would you consider?
We have two types of data for caching: text-based data associated with
the media and the media (just images for now) themselves. If we were
adding this to our engineering design document, we would also want to
include a list of some of the trade-offs we considered when coming to the
aforementioned solutions. An excerpt from a design document would look
like the following.
Example Document
This document focuses specifically on the caching portion of the
Photo Stream application. To properly support the broader product
requirements, our caching solution must evict expired images (after
24 hours). Additionally, when necessary, we will use the cursor-based
pagination method to request more data from the server. The broader
design document will outline how and when we request more data from
the server.
We must support text-based caching to meet the outlined product
requirements and provide a performant user experience. To do so, we can
91
Chapter 3 iOS Persistence Options
b. No actual use case that Realm solves here – this may change
in the future.
c. Additional maintenance burden from Realm as a third-party
library.
92
Chapter 3 iOS Persistence Options
b. This will work now since our current data do not utilize any
relationships. However, our feature plans for the application
involve expanding to include relationships and a more
complex data model, so we believe going with this option is
short-sighted.
93
Chapter 3 iOS Persistence Options
Now we must define our managed object for interacting with Core
Data and a codable struct for decoding from JSON. For simplicity, the
codable struct will also double here as the properties object for batch
loading data in Core Data. For this, we implement the PhotoProperties
struct and conform to the codable protocol to support decoding and
encoding from JSON.
We also conform to the ManagedObjectPropertiesProto, which is
utilized to interface with Core Data, and the ConvertToDomainProto,
which allows for the conversion of our underlying data layer object to the
plain old Swift object used in the data presentation layer.
// The keys must have the same name as the attributes of
the entity.
func getDictionaryValue() -> [String: Any] {
94
Chapter 3 iOS Persistence Options
return [
"thumbnailUrl": thumbnailUrl,
"url": url,
"title": title,
"albumId": albumId,
"id": id
]
}
}
Here, we define the Core Data required data layer object. Within the
Core Data object, we also define typealiases for the DomainObject and
PropertiesObject. This allows us to convert our networking and Core Data
objects to the required data presentation layer struct in our repository.
import Foundation
import CoreData
95
Chapter 3 iOS Persistence Options
protocol ConvertToDomainProto {
associatedtype DomainObject
func convertToDomain() -> DomainObject
}
Now that our data objects are defined, we can express our
repository, networking, and Core Data caching layers to complete the
implementation. Here, we utilize URLSession API and wrap it in a custom
protocol to support dependency injection (we will discuss it in depth in
Chapter 6). The networking and Core Data layers are generic to be usable
with any objects with the correct properties defined.
96
Chapter 3 iOS Persistence Options
97
Chapter 3 iOS Persistence Options
import CoreData
import Combine
final class CoreDataManager: LocalStorageManagerProto {
private let inMemory: Bool
private let container: NSPersistentContainer
private var notificationToken: NSObjectProtocol?
// A peristent history token used for fetching transactions
// from the store.
private var lastToken: NSPersistentHistoryToken?
init(
persistentContainer: NSPersistentContainer,
inMemory: Bool = false
) {
self.inMemory = inMemory
self.container = persistentContainer
// Observe Core Data remote change notifications on the
queue where the changes were made.
notificationToken = NotificationCenter.default.addObserver(
forName: .NSPersistentStoreRemoteChange,
object: nil,
queue: nil) { note in
print("Received a persistent store remote change
notification.")
98
Chapter 3 iOS Persistence Options
Task {
await self.fetchPersistentHistory()
}
}
}
deinit {
if let observer = notificationToken {
NotificationCenter.default.removeObserver(observer)
}
}
func getAllEntities<T>(
for entityName: String,
_type: T.Type) throws -> AnyPublisher<[T], Error> {
let taskContext = newTaskContext()
return taskContext.performAndWait {
let request = NSFetchRequest<NSFetchRequestResult>(
entityName: entityName)
guard let fetchResult = try? taskContext.
execute(request),
let getResult = fetchResult as? NSAsynchronous
FetchResult<NSFetchRequestResult>,
let mos = getResult.finalResult as? [T] else {
return Fail(error: CoreDataError.fetchError)
.eraseToAnyPublisher()
}
return CurrentValueSubject(mos).eraseToAnyPublisher()
}
}
// skip methods for deleting and importing data...
}
99
Chapter 3 iOS Persistence Options
import Foundation
import Combine
init(
localStorageManager: LocalStorageManagerProto,
networkManager: NetworkManagerProto
) {
self.localStorageManager = localStorageManager
100
Chapter 3 iOS Persistence Options
self.networkManager = networkManager
// for usage in the example, we are setting to an old time
// interval to ensure first network fetch
self.lastFetchTime = Date(timeIntervalSince1970: 0)
}
101
Chapter 3 iOS Persistence Options
return sharedPublisher
.compactMap { photos in
let objs = photos.compactMap { photo in
photo.convertToDomain()
}
return objs
}
.eraseToAnyPublisher()
}
}
102
Chapter 3 iOS Persistence Options
We will discuss each area and combine them into our final architecture
diagram in Figure 3-13.
103
Chapter 3 iOS Persistence Options
out of space. While NSCache provides its own cache eviction strategies, a
custom class eviction strategy is also required to ensure that expired posts
are purged from the cache. To avoid unnecessary disk access, it is also
helpful to have an in-memory cache. By default, UIImage provides an in-
memory cache, and we will utilize that here.
imageView.setImageWithURL(imageURL,
{ (image, error?, cacheType, imageURL) in {
// completion code here ...
}
)
imageManager.loadImageWithURL(imageURL
progress:{ (receivedSize, expectedSize) in {
// progress tracking code here ...
}
completed:{
(image, error?, cacheType, finished,
imageURL) {
104
Chapter 3 iOS Persistence Options
if (image) {
// completion code here ...
}
})
Wrapping Up
The preceding example illustrates the thought process and trade-offs
that ultimately result in the finished engineering design document. The
solution outlined earlier is typically already implemented at most large
companies either via a custom solution or utilizing a well-known
third-party library (such as SDWebImage for media caching). As an
engineer, it is helpful to truly understand the service these libraries provide
and where the data is stored. It is easy to overlook certain aspects without
truly understanding the underlying fundamentals, such as if the current
solution uses an in-memory cache or is it always reaching out to the disk?
Is it possible that the most important images aren’t cached? Without
first understanding the current cache eviction strategy, it is impossible
to reason about the solution. Additionally, your application may have
105
Chapter 3 iOS Persistence Options
Summary
Deep-diving into SQLite is an exciting look at a well-designed piece of
software. Also, it provides valuable insights into why databases work the
way they do and how we can ensure our code uses them optimally. For
example, we saw the concept of well-placed indices came up multiple
times. Beyond SQLite, we looked at Core Data as a common API
abstraction and other storage APIs with specific uses (NSUserDefaults
and the keychain). We then looked at a practical example of building a
caching solution. This example illustrated some of the trade-offs and
common usages of the different storage options we discussed in the
chapter. Whether you use a different wrapper over SQLite, NSUserDefaults,
or the keychain, the core concepts around how these solutions best serve
you still apply. The security risks presented by NSUserDefaults are also an
106
Chapter 3 iOS Persistence Options
107
Chapter 3 iOS Persistence Options
F urther Learning
1. Preferences and settings guide:
a. https://developer.apple.com/library/archive/
documentation/Cocoa/Conceptual/UserDefaults/
Introduction/Introduction.html#//apple_ref/doc/
uid/10000059i
2. SQLite documentation:
a. www.sqlite.org/
a. www.objc.io/issues/4-core-data/SQLite-instead-of-
core-data/
4. Apple file system:
a. https://developer.apple.com/library/archive/
documentation/FileManagement/Conceptual/
FileSystemProgrammingGuide/FileSystemOverview/
FileSystemOverview.html#//apple_ref/doc/uid/
TP40010672-CH2-SW1
5. Core Data guide:
a. https://developer.apple.com/documentation/coredata
108
CHAPTER 4
Concurrent
Programming
Overview
Concurrent programming is essential to application development; it
allows your iOS application to perform multiple operations simultaneously
while allowing the user to interact with the application seemlessly.
However, with great power comes great responsibility, as concurrent
programming presents some of the most challenging aspects of
application development. To mitigate some of the challenges when using
concurrent programming, it is crucial to have a well-designed system that
considers the fundamental concepts of concurrent programming and the
capabilities of the iOS ecosystem.
1. Common pitfalls
110
Chapter 4 Concurrent Programming
Concurrency
In its most simplistic sense, concurrency is interleaving the execution
of multiple tasks instead of executing each task sequentially. In
programming, the “multiple tasks” are synonymous with having multiple
logical threads of control. These threads may or may not run in parallel.
Multithreading
Multithreading specifically refers to computing with multiple threads of
control. Once created, a thread performs a computation by executing a
sequence of instructions, as specified by the program, until it terminates. A
program starts a main thread that can then create or spawn other threads
and synchronize with other threads using a variety of synchronization
constructs, including locks, synchronization variables, mutexes, and
semaphores.
Figure 4-1 represents multithreaded computation as a directed acyclic
graph (DAG). Each vertex represents the execution of an instruction (an
addition operation, a memory operation, a thread spawn operation, etc.).
111
Chapter 4 Concurrent Programming
Parallelism
Parallelism is when multiple tasks are performed in parallel. A parallel
program may or may not have multiple threads. Parallelism could take
the form of using multiple cores on a processor or multiple servers. In this
way, we can write parallel software by utilizing structured multithreading
constructs. Figure 4-2 compares concurrent execution to parallel
execution. In this example, the parallel execution can be considered two
separate processes.
112
Chapter 4 Concurrent Programming
Asynchronous Programming
Asynchronous programming is a separate, often related concept referring
to the fact that two events may occur at different times. Figure 4-4
illustrates the difference between a synchronous and an asynchronous
execution. The actors correspond to threads in the following example;
however, they could also be processes or servers. The most common use
case of asynchronous programming in iOS is using GCD (Grand Central
Dispatch) to dispatch a networking call to a background thread while
allowing the main thread to stay unblocked for UI updates.
113
Chapter 4 Concurrent Programming
1
www.gotw.ca/publications/concurrency-ddj.htm
114
Chapter 4 Concurrent Programming
Cost of Concurrency
Before diving into implementing concurrency in iOS, we will look at some
of the trade-offs involved. Even though concurrency is a must for modern
iOS applications as it allows them to process tasks in the background
while unblocking the main thread for user interaction, it does have some
drawbacks and performance considerations that we must account for.
Thread Cost
Creating threads costs your program in both memory use and
performance. Each thread requires memory allocation in both the kernel
memory space (core structures for thread management and scheduling)
and your program’s memory space (thread-specific data). The kernel
memory requirement is 1 KB. Additional threads, other than the main
thread, have an added in-memory cost, with the minimum stack size for a
secondary thread being 16 KB; however, the actual stack size could change
depending on the use case, as illustrated in the following code sample:
115
Chapter 4 Concurrent Programming
newThread.name = "secondary"
print("second thread with default size",
newThread,
newThread.stackSize)
116
Chapter 4 Concurrent Programming
Difficult to Debug
Writing and testing concurrent programming can be difficult, especially
because many of the bugs are due to the nondeterministic execution of
the code. Luckily Xcode provides tools to help identify threading issues,
including the Thread Sanitizer (which detects race conditions between
threads) and the Main Thread Checker (which verifies that system APIs
that must run on the main thread actually do run on that thread). However,
even when using these tools, debugging concurrency-related bugs can
still require long periods of time spent reading stack traces and adding
additional logging.
117
Chapter 4 Concurrent Programming
Implementing Concurrency
In this section, we will cover a few different concurrency models starting
with the most basic implementation of threads and locks. In each section,
we will tie the implementation to architectural best practices.
118
Chapter 4 Concurrent Programming
class Bank {
let name: String
var balance: Int
119
Chapter 4 Concurrent Programming
class ProgramDriver {
let bank = Bank(name: "PNC", balance: 1200)
func executeTransactions() {
let thread = Thread(target: self,
selector: #selector(t1),
object: nil)
let thread2 = Thread(target: self,
selector: #selector(t2),
object: nil)
thread.start()
thread2.start()
}
120
Chapter 4 Concurrent Programming
ProgramDriver().executeTransactions()
122
Chapter 4 Concurrent Programming
While adding the lock has seemingly solved the race condition at a
larger scale, this would involve adding in locks across the code base and in
general requires precise unlock location. Using GCD typically provides a
more robust, easy-to-use alternative.
123
Chapter 4 Concurrent Programming
124
Chapter 4 Concurrent Programming
125
Chapter 4 Concurrent Programming
} else {
print("\(self.name): Insufficient balance")
}
}
}
}
126
Chapter 4 Concurrent Programming
Along with our concurrent queue, we will need to use a GCD barrier
flag. A GCD barrier creates a synchronization point within a concurrent
dispatch queue. A barrier flag is used to make access to a certain resource
or value thread-safe. This allows our concurrent queue to synchronize
write access while we keep the benefit of reading concurrently. Figure 4-6
highlights the differences in execution between the serial, concurrent, and
concurrent plus barrier flag queues we discussed.
127
Chapter 4 Concurrent Programming
128
Chapter 4 Concurrent Programming
One additional concept that is worth noting about all of our concurrent
approaches is that we have encapsulated the concurrent code so that
a caller does not need to worry about how to manage the concurrent
operations. In our toy example, this is trivial; however, in more complex
applications, understanding at what level of abstraction you choose
to handle concurrent code in your application is an important design
decision when developing a library. Typically, it is best practice to manage
it at the library level instead of leaving it up to users of your API to manage
the concurrent states. It is also essential to annotate the thread-safety level
of your code clearly.
Operation Queues
Operation queues are a high-level abstraction on top of GCD. Instead of
starting the operation yourself, you give the operation to the queue, which
then handles the scheduling and execution.
An operation queue executes its queued objects based on their priority
and in FIFO order and performs its operations either directly by running
them on secondary threads or indirectly using GCD.
129
Chapter 4 Concurrent Programming
Swift Concurrency
Swift introduced actors and support for asynchronous functions via the
async and await keywords as part of a new way to interact with concurrent
code. The async and await keywords are relatively straightforward and
130
Chapter 4 Concurrent Programming
Async Functions
Asynchronous function annotations help structure concurrent code in
a readable and maintainable manner compared to completion blocks
associated with dispatch queues. While the asynchronous function
syntax is straightforward, the actual execution is a bit more complex.
Before diving in, let us review how synchronous functions execute. In a
synchronous function, every thread in a running program has one stack,
which it uses to store state for function calls. When the thread executes a
function call, a new frame is pushed onto its stack (as outlined in Chapter 2).
Now for asynchronous functions, we have a slightly more complex layout
where calls to an asynchronous function (annotated by the await keyword)
are stored on the heap with a pointer from the stack.
For asynchronous threads, there is no guarantee that the thread
that started executing the async function (before the await keyword) is
the same as the function that will pick up the continuation (breaking
atomicity). In fact, await is an explicit point in code that indicates that
atomicity is broken since the task may be voluntarily de-scheduled.
Because of this, you should be careful not to hold locks across an await
that, as we will discuss in more detail later, breaks the Swift runtime
contract of making forward progress.
131
Chapter 4 Concurrent Programming
Actors
The actor model is a versatile and reliable programming model used
for concurrent tasks. It is commonly used in distributed systems like
Erlang and Elixir. In this model, actors are the core element of concurrent
computation. When an actor receives a message, it can make local
decisions, create other actors, and send messages. Actors can modify their
own private state but can only interact indirectly with each other through
messaging, which eliminates the need for lock-based concurrency.
Actors are a new fundamental type in Swift that has many benefits for
concurrent programming:
Swift Tasks
As mentioned earlier, Swift structured concurrency utilizes async and
await statements to provide a readable and more intuitive way to architect
your concurrent code. However, we still need a way to support concurrent
execution. To support this, Swift includes the concept of a task, where
multiple tasks can execute in parallel to, for example, download many
images at once to your cache.
In order to run asynchronous code concurrently, a task creates a new
execution context. These tasks are incorporated into the Swift runtime,
which helps prevent concurrency bugs and schedules tasks efficiently. It’s
important to note that calling an async function does not automatically
create a new task – tasks must be explicitly created within the code.
132
Chapter 4 Concurrent Programming
Structured Tasks
Swift structured tasks have built-in features that allow for cancellation
of tasks and handling of abnormal task exits caused by errors or failures.
If a task exits abnormally, Swift will automatically mark it as canceled
and wait for it to finish before exiting the function. Canceling a task
does not stop it but rather informs the task that its results are no longer
needed. Additionally, Swift maintains a mapping of structured task
execution, so when a task is canceled, all its descendants are also canceled
automatically.
For example, if we implement URLSession with structured tasks to
download data and an error occurs, the individual structured tasks will
be marked for cancellation. The asynchronous function containing our
URLSession will exit by throwing an error once all structured tasks created
directly or indirectly have finished. Once a task is canceled, it is up to your
code to appropriately wind down execution and stop. This is important
for specific situations, such as if a task is in the middle of an important
transaction where immediately stopping the task is inappropriate.
Unstructured Tasks
So far, we have discussed Swift structured tasks that provide many great
benefits; however, they are constraining as the system maintains the
lifetime of the task and the priority (based on the contextual execution
context). Sometimes, more custom implementations are needed, which
is where unstructured tasks come in. They provide the ability to create
tasks where the lifetime is not confined to any scope. Because of this,
they can be launched from anywhere (even non-async functions) and
require manual cancellation, life cycle management, and the developer to
explicitly await the task (all things that Swift structured tasks would handle
for you).
133
Chapter 4 Concurrent Programming
134
Chapter 4 Concurrent Programming
actor Bank {
let name: String
var balance: Int
135
Chapter 4 Concurrent Programming
Thread.sleep(
forTimeInterval: Double.random(in: 0...2))
self.balance -= value
print("\(self.name): Done: \(value) withdrawn")
print("\(self.name): Current balance: \(self.balance)")
} else {
print("\(self.name): Insufficient balance")
}
}
Now we also have to change the way we trigger our balance to conform
to the new framework.
class ProgramDriver {
let bank = Bank(name: "PNC", balance: 1200)
136
Chapter 4 Concurrent Programming
Task{
await ProgramDriver().executeTransactions()
}
137
Chapter 4 Concurrent Programming
2. Networking module:
138
Chapter 4 Concurrent Programming
Now we can sketch out the pseudocode for this using mostly standard
iOS libraries:
139
Chapter 4 Concurrent Programming
Rough Edges
Despite all the shiny new features, Swift concurrency still has some
inherent costs, including additional memory allocation and management
overhead. Just because Swift concurrency has easier-to-use syntax does
not mean it cannot be overused.
140
Chapter 4 Concurrent Programming
func updateFoo() {
let semaphore = DispatchSemaphore(value: 0)
Task {
await asyncUpdateFoo()
semaphore.signal()
}
semaphore.wait()
}
3. Compile-time safety.
141
Chapter 4 Concurrent Programming
142
Chapter 4 Concurrent Programming
Let us say we have thread A and thread B both reading the balance
from memory; let’s say it is 1000. Then thread A deposits money into the
account, increments the counter by 200, and writes the resulting 1200
back to memory. At the same time, thread B also increments the counter
by 400 and writes 1400 back to memory, just after thread A. The data has
become corrupted at this point because the counter holds 1400 after it was
incremented twice from 1000 (see illustration in Figure 4-9).
143
Chapter 4 Concurrent Programming
Deadlock
Following along with our banking example, to solve a race condition, we
can introduce locks. However, locks introduce the potential for a deadlock.
A deadlock occurs when multiple threads are waiting on each other to
finish and get stuck.
To demonstrate deadlock, we will use a common academic example
called the “dining philosophers” problem. Imagine four philosophers
sitting around a table with four (not eight) chopsticks arranged as drawn in
Figure 4-10.
struct Philosophers {
let left: DispatchSemaphore
let right: DispatchSemaphore
144
Chapter 4 Concurrent Programming
var leftIndex = -1
var rightIndex = -1
init(left: DispatchSemaphore,
right: DispatchSemaphore) {
self.left = left
self.right = right
}
func run() {
while true {
left.wait()
right.wait()
print("Start Eating…")
sleep(100)
print("Stop eating, release lock…")
left.signal()
right.signal()
}
}
}
Now, we could run this on our computer, and it will run successfully
for a while. However, it will eventually stop working. This is because if
all the philosophers decide to eat at the same time, they all grab their
left chopstick and then find themselves stuck in a situation where each
has one chopstick and each is blocked, waiting for the philosopher on
their right.
145
Chapter 4 Concurrent Programming
https://github.com/raywenderlich/swift-algorithm-club/blob/
master/DiningPhilosophers/Sources/main.swift
Priority Inversion
Priority inversion occurs when a lower-priority task blocks a higher-
priority task from executing, effectively inverting task priorities. More
specifically, priority inversion can occur when you have a high-priority
and a low-priority task sharing a common resource and the low-priority
task takes a lock to the common resource. The low-priority task is
supposed to finish quickly and release its lock, allowing the high-priority
task to execute. However, after the low-priority task executes, there is a
small amount of time for the medium-priority task to take priority and
run because the medium-priority task is now the highest priority of all
currently runnable tasks. At this moment, the medium-priority task stops
the high-priority task from acquiring the lock, further blocking the high-
priority task from running. GCD exposes applications to this risk because
the different background queues can have different priorities (one is even
I/O throttled). As iOS engineers, we need to be aware of priority inversion.
Priority inversion is graphically illustrated in Figure 4-11.
146
Chapter 4 Concurrent Programming
Thread Explosion
After reading the section on GCD queues, it might be tempting to make
numerous queues to gain better performance in your application without
worrying about creating too many threads. Unfortunately, this can lead to
excessive thread creation. Excessive thread creation can occur when
147
Chapter 4 Concurrent Programming
148
Chapter 4 Concurrent Programming
149
Chapter 4 Concurrent Programming
This also meant moving away from the Core Data framework, which
guarantees strong data consistency but comes at the cost of performance.
The discussion and outcome of Meta’s engineering challenge are
especially relevant for two reasons:
2
https://engineering.fb.com/2014/10/31/ios/making-news-feed-
nearly-50-faster-on-ios/
150
Chapter 4 Concurrent Programming
151
Chapter 4 Concurrent Programming
1. Design
2. Maintenance
3. Safety
4. Scalability
Throughout the proposal, Lattner outlines core principles and why the
actor model proposed satisfies them. He mentions explicitly how a shared
mutable state is bad and how the actor model prevents this. Overall,
Lattner touches on the same principles outlined in this chapter:
1. Avoid shared mutable state.
2. Reliability.
3. Scalability.
3
https://gist.github.com/lattner/31ed37682ef1576b16bca1432ea9f782#ove
rall-vision
152
Chapter 4 Concurrent Programming
Summary
Many higher-level abstractions can be added to improve application
developers’ use of concurrent programming. Different companies
may even have more custom abstractions over GCD or custom C-level
frameworks with their own APIs and way of managing concurrency.
As an engineer, it is important to understand the basics of concurrent
programming and how to best leverage the provided abstractions,
and where potential issues may occur. Some of the most complex and
inconsistent bugs are related to bugs in concurrent programming. By
understanding the fundamentals and the iOS abstractions, you can help
limit bugs in your own code and that of those you review.
Additionally, as you begin to design more complex systems that may
interact with peripherals or custom client-infrastructure stacks outside of
the iOS framework, managing concurrency will be more important and
more challenging and will require relying on the fundamental principles
and potentially dipping into the lower-level land of locks.
153
Chapter 4 Concurrent Programming
Further Learning
1. Apple documentation on dispatch queues:
a. https://developer.apple.com/documentation/DISPATCH
2. Master GCD:
a. https://cocoacasts.com/series/mastering-grand-
central-dispatch
154
Chapter 4 Concurrent Programming
a. https://go.dev/blog/waza-talk
b. www.youtube.com/watch?v=cNICGEwmXLU
4. Swift concurrency:
a. https://developer.apple.com/videos/play/
wwdc2021/10134/
b. https://developer.apple.com/videos/play/
wwdc2021/10254/
155
PART II
Application
Architecture and
Design Patterns
CHAPTER 5
The Importance
of Good Architecture
Overview
This chapter marks the start of Part 2 of this book. In the previous chapters,
we discussed Swift and iOS ecosystem concepts critical for correct
coding. Without those fundamentals, programs will not execute correctly.
However, as a senior engineer working on a production application, it is
not just about the code; there is also a need for a well-defined architecture.
A well-defined architecture promotes easier modification, ease of testing,
and better developer experience. In Part 2, we will discuss application
architecture, starting with the components and building up to the overall
application.
As software engineers, we aim to implement functional architecture for
our applications and enforce best practices within our code bases. We can
achieve good architecture in many ways (as we will see in the modularity
case studies); however, there are several fundamental principles that
almost all quality architectures follow. Using these principles provides a
template for creating well-architected applications.
2. Why it is essential
160
Chapter 5 The Importance of Good Architecture
1
Martin, R. C. (2003). Agile software development: principles, patterns, and
practices. Prentice Hall PTR.
161
Chapter 5 The Importance of Good Architecture
162
Chapter 5 The Importance of Good Architecture
protocol Canine{}
class BarkingCanines: Canine {
public void bark(){}
}
class GoldenRetriever: BarkingCanines {}
class Wolf: Canine{}
163
Chapter 5 The Importance of Good Architecture
Dependency Inversion
The principle is straightforward: ensure that high-level modules, which
contain intricate logic, can be reused without being affected by changes in
low-level modules that provide utility features. Dependency inversion is
used to achieve this by introducing an abstraction that separates the high-
level and low-level modules. To enforce this, we should ensure that
164
Chapter 5 The Importance of Good Architecture
165
Chapter 5 The Importance of Good Architecture
Modularity
The tenet of modularity transcends the SOLID design principles and
encompasses design patterns like the facade or builder pattern from
the Gang of Four. Whenever you are designing a component or broader
application, it is vital to consider how modularity applies at each level (design
patterns, distinct frameworks, and the overall application architecture).
Even with the best design patterns in place, an application can become
unwieldy if everything is coupled into a single framework or library.
This coupling leads to slow build times, complex merge conflicts, and a
complicated ownership model for large software teams. To best illustrate
the pitfalls of a tightly coupled application, let’s explore a case study of how
Uber split their ride application.
2
https://eng.uber.com/new-rider-app-architecture/
166
Chapter 5 The Importance of Good Architecture
logic, while the presenter and view handle the view logic. In this way,
RIBLETS are modular; however, they also need to interact with one
another. To accomplish this, the interactor component of the RIBLET
makes service calls to fetch data. The data flow is unidirectional from the
service to the model stream and from the model stream to the interactor.
The model stream produces immutable models, thus enforcing the
requirement that the interactor classes use the service layer to change the
application’s state.
In this way, at a feature level, an individual engineer writing a driver
rating module using RIBLETS will, by default, separate the business logic,
view logic, data flow, and routing, making a modular component. At an
application-wide level (a team of engineers working on a logging library),
this framework provides a clear separation of concerns of the different
flows in the Uber application (made up of RIBLETS) and helps prevent the
application from increasing in complexity in the future.
While RIBLET is not a traditional iOS application architecture
(although it does follow functional reactive programming data flow), it
does enforce modularity by clearly separating concerns and thus providing
a scalable foundation for Uber engineers across the company. Even if Uber
engineers had been using Gang of Four design patterns perfectly, they still
would have encountered the same issues around growing complexity due
to large modules and would need a better way to enforce modularity.
167
Chapter 5 The Importance of Good Architecture
was created to solve the problem of building user interfaces that require a
lot of imperative code.3
Initially, ComponentKit was rolled out on Facebook and provided
numerous benefits, including the following:
3
https://engineering.fb.com/2015/03/25/ios/introducing-componentkit-
functional-and-declarative-ui-on-ios/
4
https://componentkit.org/
168
Chapter 5 The Importance of Good Architecture
5
https://medium.com/airbnb-engineering/designing-for-productivity-in-
a-large-scale-ios-application-9376a430a0bf
169
Chapter 5 The Importance of Good Architecture
Wrapping Up Modularity
When designing an entire application, the design is the sum of the
individual subsystems. While the individual subsystems should utilize
design patterns and sound architecture principles, this isn’t enough if the
170
Chapter 5 The Importance of Good Architecture
Testability
A good practical architecture is easy to iterate on, expand upon, and
quickly build and iterate on features to achieve business goals. A large
portion of building and iterating on features comes down to the ease of
making changes and testing their correctness. A well-architected app with
no testing leads to slow progress since all changes require a good deal of
manual testing. In an ideal world, engineers make succinct code changes
without side effects; however, to be sure, we still need to verify this.
By having a high-quality suite of automated unit and integration tests,
developers can be reasonably certain of the correctness of their changes.
But testability does not stop here; no matter what change we make, we
need to verify that it is not regressing overall application experience.
Additionally, UI-based changes must meet design specifications for
all areas (which can require manual inspection). To help expedite this
process and ensure good coverage, teams can utilize QA resources, UI
snapshot testing, and dogfooding to gather user feedback. Performing
all three functions succinctly requires additional developer operations
infrastructure for automated build/distribution pipelines and high-
fidelity logging. These considerations are also required for high-quality
architecture and firmly fall under the banner of testability.
171
Chapter 5 The Importance of Good Architecture
172
Chapter 5 The Importance of Good Architecture
6
https://engineering.fb.com/2017/05/24/android/managing-
resources-for-large-scale-testing/
7
https://engineering.fb.com/2018/05/02/developer-tools/sapienz-
intelligent-automated-software-testing-at-scale/
173
Chapter 5 The Importance of Good Architecture
8
https://developer.squareup.com/blog/ziggurat-ios-app-architecture/
174
Chapter 5 The Importance of Good Architecture
Wrapping Up Testability
While testing is not the most glamorous software engineering task, it is very
important. Not only can testing help influence our architecture as it did
with Square’s development of Ziggurat, but it can also expose additional
opportunities, as shown in Meta’s case, where better testability became a
large investment area, including distributed systems and research-backed
ML tooling to ensure application correctness.
Choosing an Architecture
While we have discussed the principles of good architecture, we have
not discussed how to choose the right architecture. In truth, choosing
an architectural pattern is not as crucial as understanding the problems
you wish to solve and following architectural best practices to reach the
ideal state. By taking the time to understand the issues you want to tackle,
you can concentrate on the most impactful aspects of the application’s
architecture. Many of these problems will be unique to your use case
just like how Square developed Ziggurat. However, by understanding the
principles of systems design, you can apply them to any use case.
Before applying system design principles, we still do have to evaluate
the architectural decisions. To do so, we need to identify the key pain
points we are trying to solve by listening to those around us and their
problems and actively thinking about how we can do better. For example:
175
Chapter 5 The Importance of Good Architecture
Summary
Once we have gathered the necessary information for our application
architecture, we can leverage the knowledge of best practices and the
system design principles discussed here to craft the best solution. This
chapter is our first foray into application architecture. In the rest of Part 2,
we will further review design patterns to provide a solid foundation for
defining and enforcing good architecture.
The subsequent chapters in Part 2 will further break down our
architecture discussion to include covering specific design patterns
and common application architecture patterns. Design patterns and
architecture patterns make up significant portions of the overall application
development and serve as the building blocks for module applications.
176
Chapter 5 The Importance of Good Architecture
Further Learning
1. Design Patterns: Elements of Reusable Object-
Oriented Software
177
CHAPTER 6
Common Design
Patterns
Overview
Now that we have discussed the importance of good architecture and
defined it (modular and testable), we need to achieve it. To do so, we need
to start with the right building blocks, and the basic building blocks for
good architecture are design patterns. Design patterns provide a base for
developing scalable, readable, and maintainable software. Design patterns
leverage proven best practices to ensure your code is easily understood
and help to prevent your code from degrading into the proverbial ball of
spaghetti. Once we know design patterns, we can expand our scope to
application-wide architecture patterns.
1. Delegate pattern
3. Factory pattern
4. Singleton
6. Builder pattern
8. Observer
180
Chapter 6 Common Design Patterns
build size, and promote faster development and code reuse. Achieving
this requires a great deal of skill and knowledge from past experience and,
in many cases, multiple redesigns. While redesigning software is a fact
of life for software engineers, it is helpful to understand best practices to
limit redesigns. By leveraging design patterns, we can leverage the past
experiences of the broader engineering community to avoid common
causes of redesign.
181
Chapter 6 Common Design Patterns
Design Patterns
Overarching Themes
Design patterns are typically divided into having either creational,
structural, or behavioral purposes, where
1. Creational patterns focus on the process of object
creation
2. Structural patterns focus on the composition of
classes or objects
3. Behavioral patterns focus on how classes or objects
interact and distribute responsibility
Creational
Creational design patterns help create a system independent of how
objects are created and composed. Typical object-oriented creational
patterns delegate instantiation to a different object. As a mobile
application grows, creational patterns become important as a way to
manage the system’s complexity and increase modularity.
182
Chapter 6 Common Design Patterns
Structural
Structural design patterns describe ways to compose objects to create
larger structures and new functionality while keeping these structures
flexible and efficient. Object-based structural design patterns rely on
object composition, while class-based patterns rely on inheritance. We will
focus mostly on object composition patterns here.
Behavioral
Behavioral patterns describe the communication between objects
and characterize complex control flow based on how objects are
interconnected. One behavioral pattern used throughout iOS development
is the Chain of Responsibility pattern, which backs the iOS responder chain
(UI interactions such as tap). The Chain of Responsibility pattern provides
loose coupling by sending a request to an object through a set (chain) of
candidate objects where any candidate may fulfill the request at runtime.
183
Chapter 6 Common Design Patterns
Delegate Pattern
An alternative to inheritance that prompts object composition
The Problem
Suppose there is an existing class Oracle that implements a
whatIsTheMeaningOfLife method. Now in our program, we want a
custom version of this method that adds the capability of answering with
42 if we are playing on hitchhikers guide to the galaxy mode. To solve
this, we implement inheritance by creating a special subclass of Oracle
called HitchhikersOracle, and in our subclass, we will override the
whatIsTheMeaningOfLife method to customize its behavior.
This is problematic because it introduces a complex relationship
between our class and the Oracle base class. We respond to all messages
that Oracle responds to, and we must mesh our methods with the methods
in Oracle, which requires a detailed understanding of Oracle and tightly
couples the two entities, making changes difficult and subject to potential
unintended consequences.
The Solution
Enter the delegate pattern. The purpose of the delegate pattern is to allow
an object to communicate back to its owner in a decoupled way. By not
requiring an object to know the concrete type of its owner, we can write
code that is much easier to reuse and maintain. Delegation is analogous
to inheritance, where a subclass defers behavior to a parent class while
providing looser coupling.
184
Chapter 6 Common Design Patterns
Architecture
In our delegate diagram, Figure 6-1, we outline the following:
The code becomes more flexible when using the delegate protocol
instead of a concrete object or subclassing.
185
Chapter 6 Common Design Patterns
Code
To implement our code utilizing the delegate pattern, we must first create
a delegate protocol and then have the object that requires the delegate to
hold a weak reference to it.
class Oracle {
weak var delegate: OracleDelegate?
func whatIsTheMeaningOfLife() {
guard let d = delegate else { return }
print(d.whatIsTheMeaningOfLife())
}
}
We will also use our delegate for our special HitchhikersGuide version.
class HitchhikersGuideToTheGalaxyOracle {
func whatIsTheMeaningOfLife() {
return "42"
}
}
Lastly, we can connect our delegate to our oracle so that the oracle can
properly delegate behavior.
let h = HitchhikersGuideToTheGalaxyOracle()
let oracle = Oracle()
oracle.delegate = h
oracle.whatIsTheMeaningOfLife()
// 42
186
Chapter 6 Common Design Patterns
class OracleClosure {
private let meaningOfLife: () -> Void
func whatIsTheMeaningOfLife() {
meaningOfLife()
}
}
// playing on nihilist mode
let meaningOfLife = {
print("there is none")
}
let newOracle = OracleClosure(predicate: meaningOfLife)
newOracle.whatIsTheMeaningOfLife()
// there is none
Trade-Offs
The delegate pattern is a huge part of the Apple ecosystem. It is necessary
for using many built-in functions, such as UITableViewControllers, making
them a great choice for many situations.
187
Chapter 6 Common Design Patterns
The Solution
Enter the facade pattern. A facade is an object that provides a simple
interface to a complex subsystem. A facade wraps the functionality of a
complex subsystem in an easily usable external API. While this provides
less flexibility compared to working with the subsystem directly, it should
include only those features that clients really care about and greatly
enhance developer speed and overall maintainability.
188
Chapter 6 Common Design Patterns
Architecture
The facade provides convenient access to a subsystem’s functionality.
Underneath the facade, the underlying library knows how to direct the
client’s request and utilize its own classes and subsystems to achieve the
desired functionality.
The client uses the facade directly to avoid having to utilize the
subsystem libraries. In this situation, the complex subsystem is a
networking library consisting of dozens of objects related to caching
and network requests. Behind the facade, the objects are correctly
orchestrated, allowing you, as the user of the networking library, to call
a method instead of directly orchestrating all the objects for the network
request. As illustrated in Figure 6-2, our client only needs to care about
making a request. The other details on executing the request are abstracted
into the complex subsystem.
189
Chapter 6 Common Design Patterns
Pseudocode
Because this pattern mostly deals with linking to subsystems, there is not
a specific code sample in this chapter. However, in Chapter 3, the practical
example implements a repository pattern, which is an example of a facade.
Additionally, Chapter 7 implements the model view controller (MVC)
190
Chapter 6 Common Design Patterns
architecture pattern utilizing the facade. Please reference those for working
code samples. Here, we outline the pattern without all implementation
details starting with a NetworkFacade class that encompasses many
complex dependencies.
class NetworkFacade {
// local vars required for the Facade
//potential examples
let cache: CacheProto
let socket: SocketProto
let mediaUploader: MediaUploaderProto
networkFacade.request(request: myNetworkRequest)
Trade-Offs
In general, the facade pattern does an excellent job of isolating your code
from the complexity of a subsystem promoting weak coupling. This can
speed development and assist in separating concerns as the components
of the subsystem can be changed without affecting users of the facade.
In addition to speeding development, the weak coupling gained by using
the facade pattern helps create layers in the system. A common example in
iOS is using the facade pattern to create a data repository separating the data
layer from the application layer. A by-product of this is reduced dependencies
between objects and the ability to compile the application layer separately
from the subsystem. This reduction in compilation dependencies reduces
recompilation time for localized changes in the broader system.
191
Chapter 6 Common Design Patterns
The Problem
You are working for a mobile gaming startup building a new, never-before-
seen expansion pack for Monopoly atop their existing game engine. The
game engine consists of many complex objects and requires step-by-step
initialization of many fields and nested objects since they are used for
many different types of games. The initialization code is buried inside a
monstrous constructor with many parameters, making it difficult to reason
about as you try to construct and subclass the correct objects for your turn-
based game.
192
Chapter 6 Common Design Patterns
With the expansion pack, the Monopoly board will still have a theme,
a starting balance, and many other shared properties of the existing game
board class. However, they will also feature increased properties, new
property colors, and a higher starting balance. To solve this, you consider
extending the base game board class and creating a set of subclasses to
cover your combination of the required parameters. While investigating
the merits of this approach, developers on some other games have already
started doing this, and the application is becoming bloated with these
subclasses as any new parameter further grows this hierarchy.
Brainstorming other approaches, you consider simply creating a
giant constructor right in the base class with all possible parameters that
control the object. While this approach eliminates the need for subclasses,
it creates another potentially more harmful problem. A constructor with
many potentially unused parameters, for example, none of the expansion
pack options, will be included in the standard Monopoly game.
The Solution
Enter the builder pattern. The builder pattern is a creational design pattern
that allows for the construction of complex objects in a step-by-step
manner and allows for the creation of different types and representations
of an object with the same underlying construction code. This reduces
the need to keep mutable states, resulting in simpler and generally more
predictable objects. Additionally, by enabling objects to become stateless
and allowing the creation of different representations of an object, the
builder pattern promotes easier testing.
Note While the explicit builder pattern is not common with Apple’s
platforms, it is more common at the application level.
193
Chapter 6 Common Design Patterns
Architecture
The builder pattern consists of three core entities:
194
Chapter 6 Common Design Patterns
Code
enum Theme: String {
case `default`, darkMode, monopoly
}
// Monopoly does not share a common protocol with other games
195
Chapter 6 Common Design Patterns
struct MonopolyGame {
let maxNumPlayers: Int
let theme: Theme
let startingBalance: Double
init(maxNumPlayers: Int,
theme: Theme,
startingBalance: Double) {
self.maxNumPlayers = maxNumPlayers
self.theme = theme
self.startingBalance = startingBalance
}
func printObj() {
print("maxNumPlayers: \(maxNumPlayers), " +
"theme: \(theme), " +
"startingBalance: \(startingBalance)")
}
}
protocol MonopolyGameBuilderProto {
func setTheme(_ theme: Theme)
func setStartingBalance(_ startingBalance: Double)
func setMaxNumPlayers(_ maxNumPlayers: Int)
func reset()
func build() -> MonopolyGame
}
196
Chapter 6 Common Design Patterns
func reset() {
self.theme = .default
self.maxNumPlayers = 0
self.startingBalance = 200
}
197
Chapter 6 Common Design Patterns
func buildStandardMonopolyGame() {
// separate enum not documented here that
//contains UI theme information
builder.reset()
builder.setTheme(.monopoly)
builder.setStartingBalance(200)
builder.setMaxNumPlayers(8)
}
gameBuilder.buildStandardMonopolyGame();
// Here final object is retrieved from the builder
// object directly since the director isn't
// aware of and not dependent on concrete
// builders and products.
let game = monopolyBuilder.build()
game.printObj()
}
}
GameManager().makeMonopolyGame()
// maxNumPlayers: 8, theme: monopoly, startingBalance: 200.0
198
Chapter 6 Common Design Patterns
struct MonopolyGame {
var maxNumPlayers: Int = 0
var theme: Theme = .default
var startingBalance: Double = 200
func printObj() {
print("maxNumPlayers: \(maxNumPlayers), " +
"theme: \(theme), " +
"startingBalance: \(startingBalance)")
}
}
class MonopolyGameBuilder {
private var maxNumPlayers: Int = 0
// separate enum not documented here that contains
// UI theme information
private var theme: Theme = .default
1
https://forums.swift.org/t/url-formatstyle-and-parsestrategy/56607
199
Chapter 6 Common Design Patterns
func reset() {
// reset builder values
}
200
Chapter 6 Common Design Patterns
monopolyGame.printObj()
// maxNumPlayers: 10, theme: default, startingBalance: 100.0
Trade-Offs
The builder pattern is a creational pattern and is generally a good choice
when objects have a large number of fields in the constructor.
Pros
Cons
201
Chapter 6 Common Design Patterns
The Problem
You are a new engineer on a legacy application. Your first project is to
utilize the newly developed in-house logging framework for replacing
an older third-party framework that was no longer viable. Both loggers
implement the majority of the same methods; however, the old third-
party framework has additional functionality for attribution tracking from
advertising campaigns, so it cannot be completely removed until the new
logger also supports this functionality.
Currently, the logging is sprinkled throughout the code and sometimes
initialized slightly differently, tightly coupling the existing code to the logger
framework’s usage. You must sprinkle if statements throughout the code
to implement the needed functionality. To make your life easier, you wrap
both logger frameworks in a shared protocol that defines the functionality
of both. This way, when the third-party framework is deprecated, you can
easily remove it. But you notice that you have to instantiate each logger
multiple times throughout the code base and that the initialization itself is
somewhat long and repetitive with a few minute changes. Sighing, you start
thinking about how to abstract some of this logic.
The Solution
Enter the factory pattern. The factory pattern is a creational design pattern
that allows us to decouple the creation of an object from its usage and
encapsulate complex instantiation logic in a single place. This allows for
the abstraction of our code, so when modifications are made to a class,
the client can continue to use it without further modification. There are
multiple factory patterns, including the factory method and abstract
factory, which we will cover in detail. Each factory pattern aims to isolate
object creation logic within its own constructor. The abstract factory
pattern is a good choice here because we can encapsulate the logging
behavior behind a shared protocol and keep our instantiation logic in one
shared location.
202
Chapter 6 Common Design Patterns
203
Chapter 6 Common Design Patterns
Note A more common use case for the factory method pattern is
using it along with an iterator to allow collection subclasses to return
different types of iterators that are compatible with the collections.
Architecture
For the factory pattern, we have a factory that handles our object creation
and creates concrete implementations of our objects that conform to a
common interface.
For the factory method, the creation logic lives in a specific create
method defined in a common interface.
For the abstract factory pattern, we abstract the creation logic to
its own entity, and both the factories and entities the factories create
subscribe to specific common protocols. The creation logic occurs in a
specific class that handles which concrete implementation to instantiate;
in Swift, it is common to use a switch statement at the factory level.
204
Chapter 6 Common Design Patterns
Code
Factory Method
protocol LoggerService {
var id: String { get }
}
205
Chapter 6 Common Design Patterns
Abstract Factory
protocol LoggerFactory {
func create() -> LoggerService
}
// abstract factory
class AppLoggerFactory: LoggerFactory {
enum Logger {
case thirdParty
case inHouse
}
206
Chapter 6 Common Design Patterns
init(logger: Logger) {
self.logger = logger
}
Trade-Offs
1. More boilerplate – by abstracting our factories,
we do create more boilerplate code for each
new model.
2. You may want to consider other creational patterns
to see which best fits your situation. For example,
the builder pattern is a better option if you need to
create objects with complex or lengthy initialization
207
Chapter 6 Common Design Patterns
Singleton
Creational Pattern
The Problem
You are working for a large bank on their internal tools team. As part of the
office modernization, you are working to connect each employee to the
printer on their floor. Digging into the code, you see that each employee
object needs an instance of a printer to print. We want to avoid providing
each employee with their own instance of the printer for two reasons:
1. This would not model real-world behavior.
2. This could create a situation where one could not
easily understand the status of all the jobs sitting
in the physical printer’s queue (each instance of a
printer in code would have an incomplete view).
208
Chapter 6 Common Design Patterns
While this solves the immediate problem, it also allows for the
initialization of the class from anywhere. Additionally, the public
constructor becomes a dangerous opportunity for the system. What if a
developer sits in another part of the code and does not have the printer
object available? Well, they could just initialize it and use it to create
two printer objects in the code. Having multiple printer objects in code
does not model the real-world situation. It leads to the earlier problem
where we start to lose the ability to track the global printer status (such as
tracking all pages in the last hour or all current jobs in the queue).
The Solution
Enter the singleton pattern. The singleton solves this problem by ensuring
that there is only ever one global instance of the class. The singleton does
this by providing a unified access point for resources or services shared
across the application.
We can utilize the singleton pattern to simplify our design since having
only one instance of our printer object will better model the real-world
situation and provide us with easier insight into data, such as how many
jobs are in the printer’s queue.
209
Chapter 6 Common Design Patterns
The Architecture
The singleton class declares a static property shared that returns the same
instance of the singleton class. This static property is the only way of accessing
the singleton object. The singleton’s constructor is private and hidden from
the client code. Figure 6-6 describes the basic singleton pattern visually.
Code
In Swift, we can create singletons using a static-type property. The
statically typed property is lazily initialized only once, even when accessed
simultaneously across multiple threads.
class Printer {
static let shared = Printer()
}
210
Chapter 6 Common Design Patterns
class Printer {
static let shared: Printer = {
let instance = Printer()
// setup code
return instance
}()
Then we can access our shared instance throughout the code base.
Trade-Offs
The singleton is a controversial design pattern and requires special
consideration when using.
Pros
211
Chapter 6 Common Design Patterns
Cons
212
Chapter 6 Common Design Patterns
The Solution
Enter dependency injection. Dependency injection provides a way
to separate our concerns around constructing and using objects.
Dependency injection is one way we can ensure inversion of control in
our programs, making it easy to substitute new concrete implementations
without having to modify client code and improving testability by
providing a way to inject fully testable classes.
The Architecture
In our example, we will have the Authenticator require a logger service.
Our logger service can be created in the application delegate (or a similar
spot during application startup). Figure 6-7 depicts our new dependency
injection based architecture.
213
Chapter 6 Common Design Patterns
that in its constructor and pull it in from the higher-level module. With
much larger applications, this becomes a bit cumbersome to do in the
constructor. To address this, we can utilize a dependency injection
container.
A dependency injection (DI) container is a framework that helps
manage our application dependencies, so we do not have to create and
manage objects manually. The framework manages dependency creation
and lifetime and injects dependencies into specific classes. The DI container
creates an object of the specified type and injects the dependency objects
through a constructor, property, or method at runtime. Additionally, the
container can dispose of the dependent objects at the appropriate time.
Pseudocode
The following code is meant as a walkthrough of implementing
dependency injection and is not included in the sample code. In our
application architecture examples, Chapters 7, 8, and 9, we utilize
dependency injection throughout. They provide a holistic view of the
benefits of dependency injection in the context of a working example.
To better visualize how to implement dependency injection, let’s say
we have an additional framework named Authentication that needs to
use our logger to verify the function of the authentication flow. For our
authentication flow, we have a class named Authenticator that is located in
the Authentication framework for authenticating users.
214
Chapter 6 Common Design Patterns
Inside the authenticate method, we also want to utilize our logger for
error logging something like
So far, so good. Still, we should not use a concrete type when injecting
our LoggerService dependency since this would make switching logging
frameworks complex and require direct code change. To model this in
code, we can create a LoggerServiceProto that defines a contract that all
logger services will follow.
Now we can use that protocol to inject our LoggerService into the
Authenticator class.
func authenticate() {
// authentication code ...
// log associated event
loggerService.log(event: event)
}
}
215
Chapter 6 Common Design Patterns
@Injected(.loggerService)
var loggerService: LoggerService
@Injected
var printerService: ExternalService
216
Chapter 6 Common Design Patterns
Trade-Offs
1. Dependency injection via constructors can lead
to complex constructors and can cause one small
change to require changes in many other classes.
Coordinators
Structural Design Pattern
The Problem
Imagine you are developing the Facebook application. You start with
a simple newsfeed and code mainly in a view controller. Navigation is
simple; the user enters the application, scrolls through some posts, and
clicks to enter a basic details view. Besides the main flow, there is a simple
authentication flow and profile page. Thinking to yourself, wow, this is
easy. You quickly code the additional routing logic as part of the view
controller. Since there are only a few screens and distinct flows, you tell
yourself this is fine, and with the tight deadline, it is best this way.
217
Chapter 6 Common Design Patterns
Everything works perfectly until your project manager tells you that
groups are being added to the application. Groups are basically the same
as feed, but for a specific group of people, and from the group’s page, you
will have access to the profile flow, authentication flow, and details view
for posts. Looking at your application, you realize everything needs to
change. All the navigation logic will need additional if-else statements to
handle the new flows. After completion, you realize your code is starting to
resemble a big ball of spaghetti with classically massive view controllers,
as illustrated in Figure 6-8. You look at your routing function and think to
yourself, there has to be a better way.
218
Chapter 6 Common Design Patterns
The Solution
Enter the coordinator pattern. The coordinator pattern is a common
structural iOS design pattern that helps encapsulate different flows in
an application. The pattern was initially adapted from the application
command pattern and popularized in the iOS community as the
coordinator pattern. Coordinators help to
219
Chapter 6 Common Design Patterns
Coordinators are unique in that the pattern is adoptable for only part
of an application or as an application-wide architectural pattern (defining
the structure of an entire application). Introducing coordinators as a
portion of an existing application is an excellent feature since it makes it
easier to transition an older application to a coordinator-based one. First,
for new flows only, once the team is onboard with the concept and used to
the flow, older portions of the application can be refactored until the entire
application utilizes coordinators.
To apply coordinators in our preceding example, we could have started
out by simply adding a GroupsCoordinator for the group’s flow without
needing to change much about the existing application. Next, once
familiar with the pattern, the base application and existing newsfeed flow
can be refactored to include coordinators.
Architecture
Basic Coordinator
At the most basic level, a coordinator is a class that references a view
controller and controls its navigation flow. Typically, it can also instantiate
subcoordinators for other flows in the application. Figure 6-9 applies this
to our example using the newsfeed and posts detail view as an example.
220
Chapter 6 Common Design Patterns
Figure 6-9. Coordinator flow. Strong references are solid lines, while
weak references are dotted
221
Chapter 6 Common Design Patterns
While this basic coordinator can work well, we are making the
coordinator control the presentation of the view controllers and
navigation. We are also limiting our ability to test by not injecting
our dependencies. To fix this, we can utilize factories to abstract our
dependencies further and better follow the single responsibility principle.
This is outlined in Figure 6-11.
222
Chapter 6 Common Design Patterns
223
Chapter 6 Common Design Patterns
Application-Wide Coordinator
Now that we understand the basic logic of coordinators, we can apply them
to the entire application by starting with a base application controller.
This has the benefit of allowing us to mock routing all the way through
the application and providing an easy way to “coordinate” complex state
changes from notifications and deep links. In Figure 6-12, we outline what
that might look like in our newsfeed application.
224
Chapter 6 Common Design Patterns
Pseudocode
Now that we understand the overall flow for the coordinator pattern, let us
explore a pseudocode implementation. In Chapters 7 and 8, we include a
fully working application architecture example with coordinators.
Before creating our coordinator, let us set up our dependencies. For
the factories we inject, we will follow a standard pattern. For brevity, we
only show the CoordinatorFactory; however, all factories follow the same
pattern.
protocol CoordinatorFactoryProtocol {
func makeAuthCoordinator(
router: RouterProto,
coordinatorFactory: CoordinatorFactoryProto,
vcFactory: VCFactoryProto) -> AuthCoordinator
func makeMainCoordinator(
router: RouterProto,
coordinatorFactory: CoordinatorFactoryProto,
vcFactory: VCFactoryProto) -> MainCoordinator
func makeNewsfeedCoordinator(
router: RouterProto,
coordinatorFactory: CoordinatorFactoryProto,
vcFactory: VCFactoryProto) -> NewsfeedCoordinator
}
func makeAuthCoordinator(
router: RouterProto,
coordinatorFactory: CoordinatorFactoryProto,
vcFactory: VCFactoryProto
) -> AuthCoordinator {
225
Chapter 6 Common Design Patterns
return AuthCoordinator(
router: router,
coordinatorFactory: coordinatorFactory,
vcFactory: vcFactory)
}
// cont for other coordinators...
func start()
func start(with option: DeepLinkOption?)
}
226
Chapter 6 Common Design Patterns
func start() {
start(with: nil)
}
228
Chapter 6 Common Design Patterns
init(router: Router,
coordinatorFactory: CoordinatorFactory) {
self.router = router
self.coordinatorFactory = coordinatorFactory
}
}
With the preceding code, we can launch our application and navigate
to the main flows of our application using coordinators. Now, when we
want to add the group’s tab with access to the profile, we can simply create
a new instance of the selected coordinator.
Trade-Offs
While the coordinator pattern is widely accepted, there are some potential
trade-offs to consider when attempting to introduce the coordinator
pattern to an existing application:
229
Chapter 6 Common Design Patterns
Observer
Behavioral Pattern
The Problem
You are working on an established iOS application that uses the model
view controller architecture pattern. Recently due to an ongoing push for
monetization, your team has introduced a new shopping feature. For this
feature, when an item is added to the cart, the associated count of that
item in the shopping list view must be incremented in the cart badge icon
and any other view that tracks the cart item count. This change will require
updating multiple view controller hierarchies.
The Solution
Enter the observer pattern. The observer pattern allows for dependent
objects to be notified automatically of changes. The observer pattern
accomplishes this by defining a one-to-many dependency between
objects. We can think about this as a publisher and many subscribers
where the subscribers are notified of changes to the publisher’s state.
230
Chapter 6 Common Design Patterns
Architecture
Our implementation of the observer pattern, illustrated in Figure 6-13, has
two objects:
231
Chapter 6 Common Design Patterns
Example Code
This is the only pattern where we will leverage inheritance. Because we
are storing a reference to an array of subscribers and a reference to the
publisher, it is impossible to implement a pure protocol-oriented approach
due to generic type constraints containing self.
First, let us define our Subscriber base class. Here, we have
implemented the Equatable method to utilize our class within an array
collection.
232
Chapter 6 Common Design Patterns
Next, let us define our base publisher that will publish updates to
subscribers. Here, we have implemented add and remove to update the
subscriber list and to notify all subscribers of an update.
func notify() {
for s in subscribers {
s.update(self)
}
}
}
233
Chapter 6 Common Design Patterns
Now we can implement our observer pattern for our item shopping
cart relationship.
init(name: String,
publisher: Item) {
_publisher = publisher
super.init(name: name, publisher: publisher)
}
234
Chapter 6 Common Design Patterns
Here, we instantiate our item publisher and our cart that subscribes to
updates on the item’s count.
item.add(subscriber: cart)
item.updateItemsCount(5)
// Updated item count: 5
2
https://developer.apple.com/documentation/combine/performing-key-
value-observing-with-combine
235
Chapter 6 Common Design Patterns
One key difference here is that the Combine KVO publisher produces
the element of the observed type, whereas the KVO closure returns a
wrapped type (NSKeyValueObservedChange), which requires unwrapping
to access the underlying changed value. We will cover Combine further in
Chapter 8, when we discuss the reactive programming paradigm.
Trade-Offs
Runtime Behavior
With the observer pattern, behavior changes are distributed across
multiple objects, making it difficult to track state changes in a consistent
way (one object often affects many others).
Moreover, the observer pattern requires runtime checking of the
notification object, which means we cannot fully take advantage of the
Swift compiler and static type checking.
236
Chapter 6 Common Design Patterns
Memory Management
We must be aware of the potential for dangling references if publishers are
deleted. We can avoid dangling references by having the publisher notify
its subscribers as it is deleted so that they can adjust their reference counts
appropriately. Deleting the observers is not recommended since other
objects may reference them.
237
Chapter 6 Common Design Patterns
Summary
There are many documented design patterns, and in this chapter, we
have covered some of the most commonly used design patterns in
iOS applications. When reviewing these patterns, we focused on four
key points:
238
Chapter 6 Common Design Patterns
Further Learning
1. Design Patterns: Elements of Reusable Object-
Oriented Software
239
CHAPTER 7
Model View
Controller (MVC)
Overview
In Chapter 6, we discussed common foundational design patterns in iOS
and how they help form the foundation of good architecture by helping
you to structure your code and improve modularity and testability. In
addition to the previously mentioned design patterns, there is a class
of design patterns geared toward the iOS application layer design,
application-wide design patterns. Application-wide design patterns
address the application layer structure, how it is modularized, the control
flow, and the data flow. This is the first chapter where we will address
application-wide architecture patterns, and we will start with the most
basic of them all, the model view controller (MVC) pattern.
The MVC design pattern is also the default pattern provided by Apple.
The goal of the MVC pattern is to separate our concerns by assigning clear
responsibilities between the model, view controller, and view. This division
of labor also improves maintenance. Additionally, the MVC pattern forms
the basis of other design patterns, including the MVP (Model, View,
Presenter) pattern and the MVVM (Model, View, View-Model) pattern,
discussed in the subsequent chapter.
Figure 7-1 outlines how the MVC components act in concert to create a
fully functioning application.
242
Chapter 7 Model View Controller (MVC)
Figure 7-2 outlines an MVC hierarchy for our Photo Stream application
which we will apply our MVC architecture to in the “Practical Example”
section of the chapter. In this diagram, we assume we only want to display
the basic photo information in our photo model to the UI.
MVC Components
Now that we have reviewed the overall pattern, let’s dive into the individual
components and how they relate to our Photo Stream application.
The Model
The model object is a Swift class encapsulating the data and associated
business logic specific to the application. In our Photo Stream application,
the model specifies what information each item in our list consists of
(caption, title, description).
243
Chapter 7 Model View Controller (MVC)
The View
The view object’s responsibility is to define the UI layout and display
data from the model object to the user. The view object is typically a
descendant of the UIView and potentially linked to a storyboard or XIB. In
our example, the view defines how each item in our stream of photos is
presented to the user and what other UI components are available for
interaction.
Component Interactions
Now that we have defined our components, we need to describe how they
interact and are constructed.
Object Construction
There are different ways to approach object construction; in general,
construction should start at a high-level controller that loads and
configures views with the pertinent information from the model. A
controller can explicitly create and own the model layer or access the
model via an injected dependency.
244
Chapter 7 Model View Controller (MVC)
245
Chapter 7 Model View Controller (MVC)
Alternatively, your controller could update the view to display the data
in a different format, for example, changing the photo order from friends to
discovering new content. In this case, the controller could handle the state
change directly without updating the model.
246
Chapter 7 Model View Controller (MVC)
Practical Example
The practical examples here use Combine to create a one-way data
pipeline. We could have used either delegates, callbacks, or the
NSNotification framework. Here, we chose Combine as it provides an
observable structure (as opposed to utilizing delegates or callbacks) and
more control than notifications.
// MobileDevAtScale/Chapter 3/NetworkingLayer
photoRepository.getAll()
.receive(on: DispatchQueue.main)
.sink { result in
// potential area to handle errors and edge cases
switch result {
case .finished:
break
case .failure(let error):
print("Error: \(error)")
break
}
247
Chapter 7 Model View Controller (MVC)
protocol PhotoModelProto {
// Cannot use the @Published annotation in a
// protocol so we expose the type
248
Chapter 7 Model View Controller (MVC)
var allPhotosPublished:
Published<[PhotoModel.Photo]>.Publisher { get }
func getAllPhotos()
}
class PhotoModel: PhotoModelProto, ObservableObject {
// nested struct representing the model properties
struct Photo: ModelProto {
let albumID: Int
let id: Int
let title: String
let url: URL
let thumbnailURL: URL
}
var allPhotosPublished:
Published<[Photo]>.Publisher { $allPhotos }
@Published private var allPhotos:
[PhotoModel.Photo] = []
private var cancellables: Set<AnyCancellable> = []
249
Chapter 7 Model View Controller (MVC)
.receive(on: DispatchQueue.main)
.sink { result in
// potential area to handle errors
// and edge cases
switch result {
case .finished:
break
case .failure(let error):
print("Error: \(error)")
}
} receiveValue: { [weak self] photos in
guard let sSelf = self else {
return
}
sSelf.allPhotos = photos
.compactMap{ $0 as? PhotoModel.Photo }
}.store(in: &cancellables)
}
}
import UIKit
import Combine
250
Chapter 7 Model View Controller (MVC)
251
Chapter 7 Model View Controller (MVC)
photoModel.getAllPhotos()
}
252
Chapter 7 Model View Controller (MVC)
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let _ = (scene as? UIWindowScene)
else { return }
guard let windowScene = scene as? UIWindowScene
else { return }
let window = UIWindow(windowScene: windowScene)
// setup dependencies for injection here
let coreDataManager = CoreDataManager(
persistentContainer: container,
inMemory: false)
let networkManager = NetworkManager(
networking: URLSession.shared)
let repository = PhotoRepository(
localStorageManager: coreDataManager,
networkManager: networkManager)
let photoModel = PhotoModel(
photoRepository: repository)
let vc = PhotoStreamViewController(
photoModel: photoModel)
window.rootViewController = UINavigationController(
rootViewController: vc)
253
Chapter 7 Model View Controller (MVC)
self.window = window
window.makeKeyAndVisible()
}
One issue with the MVC pattern is that the view controller’s ability to
display data is tightly coupled to the model’s definition. To demonstrate
this, we will add reactions to our example.
To add reactions, we first need to add a Reaction object to our model.
Since reactions are tied to specific photos, let us assume we have a rockstar
team of back-end engineers who have added reactions following the JSON
API Standard1 for us to consume as a subresource in the included section.
"included": [{
"type": "reactions",
"id": "9",
"attributes": {
"thumbsUpCount": "0",
"thumbsDownCount": "0"
},
}]
Now we can add reactions to our PhotoModel. We will add this to our
model and create a fake update method that modifies the reaction count
on an update to imitate a server request (since we do not have reactions in
our sample API).
First, we create our reactions model with the data we want to capture
from the network request.
class ReactionModel {
var thumbsUpCount: Int
var thumbsDownCount: Int
1
https://jsonapi.org/
254
Chapter 7 Model View Controller (MVC)
init(
thumbsUpCount: Int = 0,
thumbsDownCount: Int = 0
) {
self.thumbsUpCount = thumbsUpCount
self.thumbsDownCount = thumbsDownCount
}
init(
albumID: Int,
id: Int,
title: String,
url: URL,
thumbnailURL: URL,
// Default value to allow fake results
reactions: ReactionModel = ReactionModel()
) {
self.albumID = albumID
255
Chapter 7 Model View Controller (MVC)
self.id = id
self.title = title
self.url = url
self.thumbnailURL = thumbnailURL
self.reactions = reactions
}
}
// ...
// fake update reactions on photo, just updates all
// photos uniformly
func updateReactionCount(
upCount: Int, downCount: Int) {
// we would send an update to our network layer,
// instead loop through and
// update all reactions. This is purely for
// illustration purposes
for photo in allPhotos {
photo.reactions.update(
upCount: upCount, downCount: downCount)
}
allPhotos = allPhotos
}
Lastly, we are ready to display our reactions to the UI. Here, we want
to allow the user to thumbs up or thumbs down a photo and track the
overall number of reactions. Monitoring and displaying the number of
reactions in each category bring up a problem. Our server does not have
a representation of how we want to show the reaction count to our users,
meaning we need to create this in the iOS application.
We can format this string in our model and expose the property for
displaying to the user, or we could format the string in the view. Either
solution is problematic. Changing the model means our model no longer
256
Chapter 7 Model View Controller (MVC)
represents our server data. And making the change in the view makes our
change more difficult to test. It violates the principle that the view should
describe a controller-agnostic container free of complex formatting (other
reaction components may not want to display this value the same way).
To increase the testability of our change, we will add the formatted
string as a property on the model; however, if we continue to do this for
a complex view hierarchy, we may end up with a very large model object
that is now handling multiple responsibilities (server data representation
and view formatting logic – not very modular).
257
Chapter 7 Model View Controller (MVC)
// PhotoStreamCollectionViewCell.swift
func configureCell(
title: String,
reactionsLabelText: String,
thumbsUpCount: Int,
thumbsDownCount: Int,
target: Any?,
sel: Selector
) {
self.title = title
reactionsView.thumbsUp = thumbsUpCount
reactionsView.thumbsDown = thumbsDownCount
reactionsView.reactionsLabelText = reactionsLabelText
reactionsView.thumbsUpButton.addTarget(
target,
action: sel,
for: .touchUpInside)
reactionsView.thumbsDownButton.addTarget(
target,
action: sel,
for: .touchUpInside)
}
Lastly, we need to connect our view to the view controller for display.
Notice we have used a helper method to abstract away some of the view
configuration logic.
// PhotoStreamViewController.swift
func collectionView(
_ collectionView: UICollectionView,
258
Chapter 7 Model View Controller (MVC)
259
Chapter 7 Model View Controller (MVC)
260
Chapter 7 Model View Controller (MVC)
Discussion
In the “Practical Example” section, we walked through building our Photo
Stream application utilizing the MVC architecture for our application
layer. We also included some of the critical decisions we faced during our
application design and example construction. This section will discuss
some of the trade-offs associated with MVC, potential effects on testability
and modularity, and the reality at scale.
Trade-Offs
We have already started discussing the potential trade-offs earlier as we
added our reactions component to our sample application. There are
several other areas that the MVC pattern does not cover particularly well,
including the following:
This is not to say that the MVC pattern is not valid; overall, the
MVC pattern allows applications to separate their main concerns and
encapsulate object functionality. Like any other application design pattern,
it will have some shortcomings. This section will address the preceding
drawbacks in our Photo Stream application.
261
Chapter 7 Model View Controller (MVC)
Modularity
We would expect a modular application to respect the single responsibility
principle by design. However, by strictly following MVC and only using
models, views, and controllers, it is typical for the code associated with
networking and routing to end up in the model or controller class. Also,
models end up mutable with inconsistent state updates because the
associated model data manipulation is in the same class as the model. This
can lead to tight coupling between components and large complex files,
commonly categorized as the massive view controller problem.
262
Chapter 7 Model View Controller (MVC)
The massive view controller can quickly appear in the MVC pattern
because there are no clearly designated places to put business logic for
applications. This situation causes many developers to place code related
to data modification in the controllers. While this is an example of a
ViewController going beyond its core purpose, this is very common due to
the lack of structure with the MVC pattern. In the end, this can lead to the
following:
263
Chapter 7 Model View Controller (MVC)
These patterns can allow us to extend our MVC application to suit the
needs of our application further and are outlined in Figure 7-5; however,
MVC is still not well suited for large-scale applications with complex user
interfaces and business logic.
264
Chapter 7 Model View Controller (MVC)
265
Chapter 7 Model View Controller (MVC)
func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: "cell",
for: indexPath
) as? PhotoStreamCollectionViewCell else {
return UICollectionViewCell()
}
cell.configureCell(
title: photos[indexPath.row].title,
reactionsLabelText: photos[indexPath.row]
.reactions
.reactionsLabelText,
thumbsUpCount: photos[indexPath.row]
.reactions
.thumbsUpCount,
thumbsDownCount: photos[indexPath.row]
.reactions
266
Chapter 7 Model View Controller (MVC)
.thumbsDownCount,
target: target,
sel: selector
)
return cell
}
func collectionView(
_ collectionView: UICollectionView,
numberOfItemsInSection section: Int
) -> Int {
return photos.count
}
}
267
Chapter 7 Model View Controller (MVC)
init(flowLayout: UICollectionViewFlowLayout) {
self.flowLayout = flowLayout
super.init()
}
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
let width = collectionView.bounds.width
let numberOfItemsPerRow: CGFloat = 1
let spacing: CGFloat =
flowLayout.minimumInteritemSpacing
let availableWidth = width - spacing *
(numberOfItemsPerRow + 1)
let itemDimension = floor(
availableWidth / numberOfItemsPerRow)
return CGSize(
width: itemDimension,
height: itemDimension)
}
}
268
Chapter 7 Model View Controller (MVC)
init(
router: RouterProto,
photoModel: PhotoModelProto
) {
self.photoModel = photoModel
super.init(router: router)
}
}
269
Chapter 7 Model View Controller (MVC)
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
// skip some setup code...
// setup dependencies for injection here
let coreDataManager = CoreDataManager(
persistentContainer: container,
inMemory: false)
let networkManager = NetworkManager(
networking: URLSession.shared)
let repository = PhotoRepository(
localStorageManager: coreDataManager,
networkManager: networkManager)
let photoModel = PhotoModel(
photoRepository: repository)
let vc = UINavigationController()
window.rootViewController = vc
self.window = window
window.makeKeyAndVisible()
applicationCoordinator = ApplicationCoordinator(
router: Router(rootController: vc),
// force to onboarding to show coordinators action
launchState: .onboarding,
270
Chapter 7 Model View Controller (MVC)
childCoordinators: [],
photoModel: photoModel)
// no deeplink support
applicationCoordinator?.start()
}
tabBar.viewControllers = [
photoStreamNavController,
settingsNavController]
router.setRootModule(tabBar, hideBar: true)
}
271
Chapter 7 Model View Controller (MVC)
problems and spend too much time attempting to architect a solution for
these problems instead of finishing the initial required work. The balance
of designing a scalable system and not over-optimizing is an art and takes
careful thought. A general rule of thumb is
Testability
As mentioned previously, we can improve our basic MVC application
by incorporating dependency injection, the facade pattern, and the
coordinator pattern. All of these patterns further abstract concerns to
their own modules, enhancing our ability to test individual components
of our application. Additionally, we can more easily mock logic in our
controllers for integration tests by abstracting our dependencies via
dependency injection. While some of our testing concerns are addressed
via dependency injection, MVC still makes testing difficult because MVC
does not provide a clear place for manipulating data from the server-based
representation to the view state representation. Commonly in MVC, the
required logic for these changes ends up in the views themselves or the
controllers, both of which are difficult to unit test.
To address the difficulty in unit testing the application, we can
rely more heavily on integration tests; however, integration tests can
be complex to set up and time-consuming to run. With an extensive
application where automated tests before landing code may only run a
272
Chapter 7 Model View Controller (MVC)
subset of tests to avoid taking too much time, unit tests become a more
effective way to get faster feedback on changes. Both MVVM and VIPER,
discussed in subsequent chapters, help pull more interactions out of the
view control to provide a more testable setup.
MVC at Scale
Before continuing, can you think of any additional concerns you would
want to address?
We will not see MVC very much at scale as it does not provide a clear
separation of concerns and often leads to massive files (not just view
controllers). Additionally, we will likely not be defining the application
architecture from scratch. Instead, we will work on an existing application
with an existing architecture. Many of these applications will have
undergone multiple architectural revisions and follow a more complex
architecture pattern, such as MVVM or VIPER. However, you may also
find yourself at a high-growth company rapidly scaling where the current
architecture is no longer functioning well.
In this situation, if the set architecture is MVC, it is your responsibility
to follow best practices to ensure the application continues to scale. If the
architecture must change, you must make a valid case based on recorded
pain points that the application architecture must transform to continue
scaling and meeting business goals. We can quantify this process into the
following steps:
273
Chapter 7 Model View Controller (MVC)
Summary
Throughout this chapter, we explored the MVC application design
pattern. We started by defining the architecture, components, and state
interactions. Next, we used our Photo Stream example to examine the
MVC architecture and some of its shortcomings critically. Finally, we
discussed potential solutions to address the problems we encountered. We
have attempted to present this architecture pattern in a similar format of
thinking critically through the architecture design process.
Overall we have discerned that MVC is a simple pattern that requires
little overhead, making it ideal for new applications, proof of concepts, and
any application that requires fast iteration, leaving room for significant
changes in the business use cases. As your application continually grows,
274
Chapter 7 Model View Controller (MVC)
you may find flaws with the initial MVC pattern that can be refactored or
rebuilt utilizing a more complex or niche design pattern. In this way, even
though MVC is not suited for large-scale applications, we can still use the
MVC pattern to help us make progress quickly and avoid overengineering
around potential problems we are unlikely to face.
There is no single solution for architecture design; every application
is unique and requires fitting together multiple design patterns to
accomplish your individual goals. This makes the MVC pattern perfectly
suitable for some cases. You must determine when these situations occur
and appropriately document them for discussion, implementation, and
retrospectives. With the practical example, we discussed our ongoing MVC
application and how we can apply architectural best practices to resolve
problems as they occur in line with the application scaling. While some of
these decisions may seem small due to the nature of the toy application in
a large-scale company, adding in coordinators and moving toward model-
based networking are enormous changes that can affect how hundreds of
engineers work. The scale makes the change require careful planning and
execution. Part 3 of this book discusses how to navigate these challenges
effectively.
275
Chapter 7 Model View Controller (MVC)
Further Learning
1. Pinterest usage of NSNotificationCenter for model
updates: https://academy.realm.io/posts/slug-
wendy-lu-data-consistency/
276
CHAPTER 8
Model View
View-Model (MVVM)
Overview
We discussed the MVC design in the previous chapter and applied it to
our Photo Stream example. To illustrate potential scaling challenges, we
discussed adding a reactions component. To show navigation challenges,
we linked our application to our coordinator example from earlier. In this
chapter, we will apply a similar format to explain the MVVM pattern and
apply it to our Photo Stream application while considering our architecture
decisions from the previous chapter.
278
Chapter 8 Model View View-Model (MVVM)
placed in the model as we did in the previous chapter. In that case, MVVM
serves to lessen the functions of the model and provides a centralized
location for transforming model values to view-centric ones.
The MVVM pattern is most useful for applications with complex
state interactions. Since applications with complex UI state interactions
typically also have complicated navigation, the coordinator pattern also
pairs well with MVVM since the view-model is coupled to the scene,
and the coordinator can orchestrate the application flow. This leaves the
view controller solely responsible for managing the view hierarchy. This
combination of design patterns nicely follows the single responsibility
principle and serves to modularize our application. Figure 8-2 outlines the
MVVM pattern combined with the coordinators for navigation. Utilizing
coordinators with MVVM does introduce another level of indirection in the
application and adds additional code necessary for feature engineering.
279
Chapter 8 Model View View-Model (MVVM)
MVVM Components
With the addition of the coordinator and view-model, our application
becomes more modular, and the responsibilities of each component
have decreased. Now we will walk through the defined components in
more detail.
The Model
The model is relatively unchanged from MVC. With MVVM, the model
object is still a Swift class encapsulating the data and associated business
logic specific to the application. Now with iterations on our Photo Stream
application, the model also encompasses reactions.
The View-Model
The view-model is the primary change between MVC and
MVVM. Traditionally, the view-model is a Swift object containing a view’s
state, methods for handling user interaction, and bindings to different
user interface elements. In our example, we decouple the view’s state to
280
Chapter 8 Model View View-Model (MVVM)
281
Chapter 8 Model View View-Model (MVVM)
The View
The view is unchanged from MVC.
Component Interactions
Object Construction
Similar to MVC and other design patterns, there are different ways to
approach object construction. Our MVVM sample project follows a similar
practice to our MVC example, where a high-level controller will load and
configure views. However, with MVVM, pertinent view information will
come from the view-model instead of directly from the model. We will also
continue to utilize dependency injection for all our resources.
282
Chapter 8 Model View View-Model (MVVM)
The View-Model
The view-model knows how to handle user interactions, like button
taps. User interactions map to methods or closures in the view-model.
The methods do some work, like telling the model to update and then
triggering state changes that result in view changes. In our example, we
abstract the state changes the view-model is responsible for to separate
structs and configure them in the view-model. Lastly, the view controller
binds to the view-model to configure the state change interaction.
283
Chapter 8 Model View View-Model (MVVM)
Practical Example
Building on our previous example, we can create our view-model for our
Photo Stream application. In our example, our view-model serves two
usages. The overall view-model for our component
Let us start by constructing the view state. To do so, we can take the
necessary properties from the UI and create a separate struct. For later
usage, we also implement the Equatable protocol.
284
Chapter 8 Model View View-Model (MVVM)
struct PhotoViewModel {
let title: String
let id: Int
let reactionsLabelText: String
let thumbsUpCount: Int
let thumbsDownCount: Int
}
285
Chapter 8 Model View View-Model (MVVM)
286
Chapter 8 Model View View-Model (MVVM)
For inputs, we want to handle when the view appears and when a user
taps on a reaction. To do so, we will define the allowable state input and
map them to combine bindings.
import Combine
struct PhotoStreamViewModelInput {
// called when a screen becomes visible
let appear: AnyPublisher<Void, Never>
// called when the user reactions to a photo
let reaction: AnyPublisher<ReactionSelection, Never>
}
For outputs, we want the view controller to know when the call to the
model to fetch data is loading, when it has completed with the constructed
view-model, and when a failure has occurred. Similar to the input struct,
we will bind our transformations to Combine.
import Combine
enum PhotoStreamState {
case loading
case success([PhotoViewModel])
case failure(Error)
}
287
Chapter 8 Model View View-Model (MVVM)
init(photoModel: PhotoModelProto) {
self.photoModel = photoModel
}
288
Chapter 8 Model View View-Model (MVVM)
func transform(
input: PhotoStreamViewModelInput
) -> PhotoStreamViewModelOuput {
input
.reaction
.sink { [unowned self] selection in
let upCount =
selection.reactionType ==
.thumbsUp ? 1 : 0
let downCount =
selection.reactionType ==
.thumbsDown ? 1 : 0
self.photoModel.updateReactionCount(
id: selection.id,
upCount: upCount,
downCount: downCount)
}.store(in: &cancellables)
let loading: PhotoStreamViewModelOuput = input
.appear
.map({_ in .loading })
.eraseToAnyPublisher()
let v = photoModel.allPhotosPublished.map {
photos in
let t = photos.map {
PhotoViewModelBuilder
.buildPhotoStreamViewModel(photo: $0)
}
return t.isEmpty ? .loading : .success(t)
}
.merge(with: loading)
.removeDuplicates()
.eraseToAnyPublisher()
289
Chapter 8 Model View View-Model (MVVM)
photoModel.getAllPhotos()
return v
}
}
Lastly, we can wire our new Combine bindings to our existing view
controller utilizing pass-through subjects. Here, we have removed our
model references to the PhotoModel and instead connect our view-model
bindings.
290
Chapter 8 Model View View-Model (MVVM)
init(photoStreamVM: PhotoStreamViewModelProto) {
self.photoStreamVM = photoStreamVM
super.init(nibName: nil, bundle: .main)
}
1
https://developer.apple.com/documentation/combine/passthroughsubject
291
Chapter 8 Model View View-Model (MVVM)
Now that we have created our view-model and added the necessary
bindings to our controller, we need to update our coordinator to reflect our
new dependency.
init(
292
Chapter 8 Model View View-Model (MVVM)
router: RouterProto,
photoModel: PhotoModelProto
) {
self.photoModel = photoModel
super.init(router: router)
}
Discussion
In the practical example, we modified our MVC application to support
MVVM. We did this by abstracting some of the logic from the model and
view controller, adding a more realistic loading state, updating our reaction
mechanism, and passing in the VC title as a param. While abstracting the
view controller title as a param is a small change, it shows a more realistic
situation where all strings are passed via params and are potentially
configured on the server for interaction with a more extensive i18n
translation system.
More significant changes included moving view state-specific logic into
the view-model and moving the necessary logic to transform our server-
driven data into the required UI representation. Given that our example is
relatively small, these changes are relatively small in magnitude. However,
the benefits are more realized in a larger-scale application with complex
data interactions and state transformations.
293
Chapter 8 Model View View-Model (MVVM)
Downsides
Trade-Offs
Overhead
The main trade-off of MVVM is the increased overhead compared to
MVC, which is well illustrated in our toy example where we created four
additional files:
1. PhotoViewModel
2. PhotoViewModelBuilder
294
Chapter 8 Model View View-Model (MVVM)
3. PhotoStreamState
4. PhotoStreamViewModelInput
There are many extra classes for enabling the relatively small benefit of
more easily implementing a loading state and reaction update. In addition
to the extra classes, we have also introduced the need for a specific reactive
programming framework.
Reactive Programming
The reactive programming style is a good choice for large-scale
applications because it enforces a predictable data model. We have
chosen to use Combine as our reactive framework in our application
since Apple provides it. Regardless of the framework selected for reactive
programming, an extra framework is required, and some application code
becomes geared explicitly toward that framework. Requiring developers to
understand the framework also makes your code more difficult to port to a
different framework.
Modularity
With the addition of the coordinator and view-model, we remove further
responsibilities held by the view controller, increasing the modularity of
our application compared to the MVC architecture. The view-model helps
us manage changes in response to a state change and how we transform
data for our UI (our reaction string).
The main advantage of the view-model is that it provides a modular
location for us to transform our model data into the view state. In MVC, we
need to decide where to place our logic to transform data for the UI and
typically choose between the existing three components (model, view,
and controller). Previously, we achieved this by modifying the data at
the model level and then passing the entire model to the view controller,
where we picked what we needed by our views.
295
Chapter 8 Model View View-Model (MVVM)
Testability
By removing references to the view and view controller, the view-model
is testable in isolation, increasing the testability of the entire application.
The MVVM pattern increases testability by allowing independent testing of
296
Chapter 8 Model View View-Model (MVVM)
Summary
MVVM is an excellent design pattern to increase the modularity of our
application and is easily extendable from a basic MVC application. By
adding additional design patterns, such as dependency injection and
coordinators, we can build an application able to scale to a large developer
and user base. Due to the increased ability to create modular and testable
code, MVVM is a much better choice for scaling an application.
In a traditional MVVM application, most of the view controller code
is replaced with a view-model that is a regular class and can be tested
more easily in isolation. To support a bidirectional bridge between the
view and the model, traditional MVVM typically implements some form
of Observables or a framework for reactive programming. Here, we have
further built on the MVVM pattern to include immutable view-models and
further modularize our code with coordinators. By further separating our
concerns, we promote ease of use for large development teams.
There are other approaches to achieve a similar separation of
concerns, including MVP and VIPER patterns. MVP is a common pattern
in Android where the presenter modifies the model and sends it to the
view layer. Here, both the
2
https://github.com/kickstarter/ios-oss
297
Chapter 8 Model View View-Model (MVVM)
view controller and the view are considered part of the view layer. The
VIPER is a more detailed and decoupled form of MVP, where, in addition
to the presenter from MVP, a separate “interactor” for business logic, entity
(model), and router (for navigation) are added. We will discuss VIPER in
the following chapter as the final application design pattern to further
show another architecture option.
Further Learning
1. Advanced iOS App Architecture: Real-World App
Architecture in Swift by the Ray Wenderlich team
298
CHAPTER 9
VIPER
Overview
So far, we have discussed MVC and MVVM architecture in our architecture
chapters. We started with MVC, the basic iOS architecture paradigm
promoted in Apple’s documentation. Then we provided examples of
modifying the pattern to become more modular and testable (better fitting
our architecture principles defined in Chapter 5). Next, we moved on to
MVVM to better address some of the scalability concerns presented by
MVC. To further scale our MVVM application, we added the coordinator
pattern, which provided an architecture that abstracted our UI business
logic, routing, and data layer, making our application more modular,
testable, and better equipped to scale.
Endless other architecture patterns strive to make applications more
modular and scalable and are less coupled to the MVC architecture.
While it is impossible to discuss every derivative found on the Internet
here, we can discuss VIPER, which encompasses the basic tenets of these
patterns. VIPER serves as a vehicle to summarize the ideas of creating an
architecture that separates all concerns from the start. Many companies
have created custom derivatives of VIPER, such as RIBLETs and Ziggurat.
To summarize the ideas of these patterns and trade-offs, we will utilize
VIPER. There is no set architecture, and VIPER and its derivatives perfectly
represent this. However, the overall concepts are similar.
300
Chapter 9 VIPER
VIPER Components
The View
The view is unchanged from MVC and MVVM. The view presents the
overall UI to the user and sends events from user interaction to the
controller.
The Interactor
The interactor contains business logic responsible for getting data from
the data layer or directly making API calls. In our implementation, the
interactor utilizes our existing repository and data layer pattern to get
domain objects. The interactor should also be completely independent of
the user interface.
The interactor can prepare or transform data from the service layer. For
example, it can sort or filter before asking for the proper network service
implementation to request or save the data. Because the interactor does
not know the view, it has no idea how the data should be prepared for the
view; that is the role of the presenter.
301
Chapter 9 VIPER
The Presenter
The presenter is the heart of the VIPER module and is the only layer
in the module that communicates with all other layers. The presenter
orchestrates all the decision-making for the module. As the central layer,
it controls reactions to user-triggered events from the view, delegates
navigation concerns to the router, and sends messages to the business
layer. Since the presenter knows of the view, it is also responsible for
preparing the data for presentation by the view.
The Entity
The entity is another name for the model, and it remains unchanged
from MVVM and MVC. In VIPER, the entity is a plain class (Swift, for our
example) and is used by the interactor. In our example, the model classes
are defined outside the VIPER module structure because these entities are
shared across the system.
The Router
The router is responsible for the navigation of the app. It has access to
the navigation controllers and windows, using them to instantiate other
controllers to push into them. The router communicates only with the
presenter and is responsible for instantiating other VIPER modules,
making it an excellent place to pass data between modules.
Component Interactions
Typically, for VIPER, the delegate pattern is utilized for communication
between components inside of the module; however, since our practical
example uses Combine for the Photo Stream module, we will utilize
Combine reactive bindings instead of delegates.
302
Chapter 9 VIPER
Object Construction
Our VIPER sample project follows a similar practice to our other examples,
where a high-level object will load and configure views. With VIPER, this
object is not a controller but a router. The router understands pertinent
information for module construction and is configured to use dependency
injection for all our resources.
The Presenter
The presenter acts as an event handler. UI events from the view controller
are forwarded to the presenter for handling. Based on the results, the
presenter can call the interactor for data model updates or the router for
presenting or dismissing a module. It’s the only class that communicates
with almost all the other components. To handle the communication in a
memory-safe way, the presenter holds a strong reference to the interactor,
a weak reference to the view controller, and a strong reference to the router.
303
Chapter 9 VIPER
Practical Example
For the practical example, we will review our MVC example from the
previous chapter and rewrite it to use VIPER. We will rewrite two of our
modules to demonstrate the VIPER architecture pattern’s use of protocols
and a way to utilize Combine with VIPER. The settings module will use
delegates, and the Photo Stream module will utilize Combine bindings.
The first step in transitioning our existing application to utilize VIPER
is to outline our application flow. In Figure 9-2, we define our module
hierarchy similar to MVVM with coordinators we have:
304
Chapter 9 VIPER
305
Chapter 9 VIPER
306
Chapter 9 VIPER
window.rootViewController = navigationController
window.makeKeyAndVisible()
}
func pushOnboardingFlow() {
// no - op
}
func pushAuthFlow() {
// no - op
}
return presenter
}
}
protocol AppPresenterProto {
func present(for launchState: LaunchState)
}
307
Chapter 9 VIPER
init (
router: AppRouterProto,
photoRepository: RepositoryProto
) {
self.router = router
self.photoRepository = photoRepository
}
308
Chapter 9 VIPER
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let _ = (scene as? UIWindowScene)
else { return }
guard let windowScene = scene as? UIWindowScene
else { return }
While other modules will mirror the structure of our Photo Stream
module for demonstration purposes, we will utilize Combine for the
Photo Stream module and the delegate pattern for additional modules
to illustrate a more standard approach to VIPER. We are staying with
Combine for the Photo Stream module because our networking logic still
utilizes Combine publishers at the data layer.
To start building our VIPER module, we will first create our interactor.
The interactor will replace the networking logic in our model so we can
copy over the code from the PhotoModel and create a new protocol for our
interactor specifying our getAllPhotos and updateReactionCount methods.
import Combine
310
Chapter 9 VIPER
func updateReactionCount(
upCount: Int, downCount: Int)
}
init(photoRepository: RepositoryProto) {
self.photoRepository = photoRepository
}
func getAllPhotos(
) -> AnyPublisher<[ModelProto], Error> {
return photoRepository
.getAll()
.eraseToAnyPublisher()
}
func updateReactionCount(
upCount: Int, downCount: Int) {
// presenter should call this method to
// handle the update, but we mock the logic
//in the presenter for demonstration purposes
}
}
311
Chapter 9 VIPER
func getAllPhotos()
func updateReactionCount(
upCount: Int, downCount: Int)
}
var loadingStatePublished:
Published<LoadingState>.Publisher { $loadingState }
@Published private var loadingState:
LoadingState = .none
312
Chapter 9 VIPER
init(
interactor: PhotoStreamInteractorProto,
router: PhotoStreamRouterProto
) {
self.interactor = interactor
self.router = router
}
func getAllPhotos() {
loadingState = .loading
interactor.getAllPhotos()
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
switch result {
case .finished:
self?.loadingState = .finished
break
case .failure(let error):
self?.loadingState = .finishedWithError(error)
break
}
} receiveValue: { [weak self] photos in
guard let sSelf = self else { return }
sSelf.allPhotos = photos
.compactMap{ $0 as? PhotoModel.Photo }
}.store(in: &cancellables)
}
func updateReactionCount(
upCount: Int,
downCount: Int
) {
313
Chapter 9 VIPER
314
Chapter 9 VIPER
router: router)
let view = PhotoStreamViewController(
presenter: presenter)
router.viewController = view
return view
}
}
init(presenter: PhotoStreamPresenterProto) {
self.presenter = presenter
super.init(nibName: nil, bundle: .main)
}
override func viewDidLoad() {
super.viewDidLoad()
navigationController?.tabBarItem.title =
"Photo Stream"
presenter
.allPhotosPublished
.sink { [weak self] ret in
self?.photos = ret
self?.collectionView.reloadData()
}
.store(in: &cancellables)
presenter
.loadingStatePublished
315
Chapter 9 VIPER
.sink { state in
switch(state) {
case .none:
break
case .loading:
print("loading results...")
case .finished:
print("finished succesfully")
case .finishedWithError(let error):
print("Finished with error: \(error)")
}
}
.store(in: &cancellables)
presenter.getAllPhotos()
}
presenter.updateReactionCount(
upCount: upCount, downCount: downCount)
}
With the Photo Stream module created, we need to wire it into the
main application flow for display. We must also construct the main tab bar
module, which makes the main application layout. Since the main layout
is just a tab bar controller, this module only requires the router component
over the VIPER acronym.
316
Chapter 9 VIPER
protocol MainRouterProto {
static func createModule(
photoRepository: RepositoryProto
) -> UIViewController
}
tabBar.viewControllers = [photoStreamNavController]
return tabBar
}
}
With the Photo Stream module complete, we can move on to our final
module, the settings module. For the settings module, we will again start
by defining our interactor. Here, the interactor is simple, and we will utilize
it to simulate a network request getting the list of items we want to display
in the settings view.
317
Chapter 9 VIPER
func getSettingsListItems() {
// mock network request
presenter?.didFetchSettings(
with: ["Account", "Privacy", "Logout"]
)
}
}
Since the presenter will use the interactor, router, and view controller
before writing the concrete implementation, we should define the
relationship via a series of protocols (in addition to the already-defined
SettingsInteractorToPresenterProto).
318
Chapter 9 VIPER
319
Chapter 9 VIPER
func didSelectRow(
_ view: UIViewController,
with item: String
) {
router?.pushDetail()
}
}
extension SettingsPresenter:
SettingsInteractorToPresenterProto {
func didFetchSettings(with settings: [String]) {
view?.settingsDidLoad(with: settings)
}
320
Chapter 9 VIPER
presenter?.viewDidLoad()
}
}
extension SettingsViewController:
SettingsPresenterToViewProto {
func showLoading() {
print("is loading")
}
func setupUI() {
title = "Settings"
}
// MainRouter.swift
let settingsVC = SettingsRouter.createModule()
Discussion
We have officially finished our VIPER sample application, marking the
end of our deep dive into iOS application design patterns. Recall we
started with MVC, the pattern most people associate with iOS application
architecture. With MVC, the controller modifies the view, accepts user
input, and interacts directly with the model. Commonly as the application
scales, the controller bloats with view logic and business logic.
321
Chapter 9 VIPER
322
Chapter 9 VIPER
Trade-Offs
VIPER is a more mature architecture path requiring many components
working in concert, which can cause additional overhead and come with
a steeper learning curve requiring engineers to think critically before
choosing. While some of the drawbacks in verbosity can be handled
by code generation tools for modules, the fact remains that VIPER,
and similar architectures, requires more code changes to build simple
components, meaning that utilizing VIPER while trying to scale quickly
and deliver business value in a fast-paced environment may put you at
odds with your architecture.
While VIPER does require additional overhead, neither MVVM nor
MVC provides the level of modularity that VIPER does by default. By
utilizing MVVM plus the coordinator pattern, we can produce a modular
application we could model similar to Figure 9-2. We can either iteratively
scale our application based on the need for a modular, scalable point
or jump into deep waters with VIPER. Of course, as illustrated in the
practical example, we do not need to utilize all VIPER components for all
modules (the main tab bar only has a router component). While there is
no perfect answer here, the choice will come down to determining the best
architecture based on its current needs, which are primarily related to the
application’s size and trajectory in terms of users, engineers working on it,
and the new features required.
Lastly, VIPER does not, by default, include a view-centric data model.
However, it can be easily adapted to provide this instead of directly using
the network-backed models. By doing so, VIPER can also account for one
of the main advantages of the MVVM pattern.
323
Chapter 9 VIPER
Advantages
1. Scalability: For large teams on complex projects,
VIPER provides a straightforward way to separate
work within modules and the modules themselves,
something that for MVC or MVVM-based
architectures requires different patterns, such as
coordinators, to achieve.
Drawbacks
1. Verbosity: With many files per module and modules,
VIPER can cause a lot of extra code not required for
certain applications.
324
Chapter 9 VIPER
Modularity
VIPER enforces modularity at a granular level by following the single
responsibility principle. At an architecture level, VIPER enforces
modularity by packaging each series of components into a self-contained
module. Because VIPER enforces modularity well, it can scale exceedingly
well by allowing large teams to work on the application seamlessly.
Additionally, we can use the self-contained modules and advanced build
systems such as Buck or Swift Package Manager only to build a subset of a
much larger application consisting of only the strictly necessary modules,
decreasing long build times and increasing developer productivity.
Testability
VIPER’s modular approach and rigid enforcement of the single
responsibility principle make it ideal for testing. We can quickly test the
VIPER modules’ individual components with unit tests and orchestrate
several modules for integration testing.
Summary
VIPER excels in stable large applications where development focuses
on adding features on top of existing ones. However, VIPER requires
substantially more code and component interactions, which can create a
lot of boilerplate and mostly unnecessary files for smaller applications.
VIPER represents a series of architectures that diverge from the typical
MVC framework to create a more modular application and has been
adapted into multiple different architectures that are often necessary for
companies at scale. While not necessarily the best architecture to start
with, VIPER represents an actual modular separation of concerns and is
applied multiple times in slightly different flavors by large tech companies
325
Chapter 9 VIPER
Further Learning
1. Clean Architecture: A Craftsman’s Guide to Software
Structure and Design (Robert C. Martin Series)
a. h ttps://developers.soundcloud.com/blog/how-we-
develop-new-features-using-offsites-and-clean-
architecture
326
CHAPTER 10
The Reactive
Programming
Paradigm
Overview
In Chapter 9, we completed our tour of the data presentation layer (UI)
architecture design patterns for iOS. In addition to data presentation
design patterns, we covered broader iOS architecture design, including
how to abstract our networking and local database code to a separate
module and common design patterns to abstract code within modules.
This chapter will cover a common approach for data flow across the entire
application. In each architecture chapter (MVC, MVVM, and VIPER),
we covered data flow at a high level and how to utilize it to manage the
application’s state more efficiently. One pattern we used to format and
display our data was reactive programming. This chapter will cover the
reactive data flow in more detail and explain how and why we have applied
functional reactive programming in our applications using Combine.
Reactive Programming
Reactive programming is a declarative programming paradigm utilizing
data streams and change propagation throughout an application. Reactive
programming allows for the expression of static (such as arrays) or
dynamic (such as event emitters) data streams easily and facilitates the
automatic propagation of the changed data flow.
Reactive programming is a popular data flow model used heavily for
systems with complex UIs, such as mobile applications, games, and VR
applications, because
328
Chapter 10 The Reactive Programming Paradigm
1
Hulasiraman, K.; Swamy, M. N. S. (1992), “5.7 Acyclic Directed Graphs,” Graphs:
Theory and Algorithms, John Wiley and Sons, p. 118, ISBN 978-0-471-51356-8.
329
Chapter 10 The Reactive Programming Paradigm
330
Chapter 10 The Reactive Programming Paradigm
To model these rules in the context of our game, we need our system to
react to events for each button and a restart if users press the play button
from the drop-down menu. Additionally, we need to track time. To do this,
we need the ability to track the system clock time inside predetermined
intervals. For reactive programming, we will need to map these input
signals and game rules into states; with this in mind, we can create five-
game states:
331
Chapter 10 The Reactive Programming Paradigm
332
Chapter 10 The Reactive Programming Paradigm
While both push and pull models work with the pull approach,
achieving total data coherence with a high publish rate is difficult. Often
data misses will occur if the pull interval exceeds the publish interval. If
the pull interval is lower, performance will suffer. Pull performs well only
if the pull interval equals the publish interval, which requires knowing the
exact publish interval beforehand. However, knowing the publish interval
ahead of time is problematic because it is rarely static and predictable.
For example, in our reflex game, we do not know when the user will tap
the screen in response. By implementing a pull-based model, we may
not accurately record when the user pressed the button, thus capturing
inaccurate reaction time data. A push method is employed for most
modern implementations.
To implement data propagation, the reactive programming framework
must maintain a dependency graph enumerating the allowable state
transformations. To model this behavior at the runtime level, we can
keep a graph of dependencies and control there execution within an
event loop. By registering explicit callbacks, the runtime creates implicit
dependencies, enforcing inversion of control. One drawback of this
approach is that making the callbacks functional (i.e., returning state
value instead of unit value) requires that the callbacks are compositional.
Figure 10-3 represents an event loop system where the UI-level application
sends tasks and the associated callback for processing. A typical example is
pushing network tasks to a background thread via the event loop to avoid
blocking main thread execution.
333
Chapter 10 The Reactive Programming Paradigm
Not only does the reactive programming runtime need to know how
to propagate events, but it also needs to know what data to propagate
on event change. When data is changed upstream, downstream nodes
affected by such changes are outdated, flagged for reexecution, and then
propagated to the subscribers. To achieve this, the entire state can be
computed each time to handle information propagation on state change,
and the previous output is ignored. Figure 10-4 outlines how a change
in the object-oriented (Swift) environment can trigger recomputation
and propagation in a reactive runtime. The Swift runtime environment
(bottom left) submits changes to reactive runtime via the propagation
algorithm (bottom right), which instructs dependent nodes (circles) in the
dependency graph (top right) to recompute by reevaluating their defining
user computations (top left).2
2
Drechsler, J. (2018). Distributing Thread-Safety for Reactive Programming
In-Progress Paper.
334
Chapter 10 The Reactive Programming Paradigm
335
Chapter 10 The Reactive Programming Paradigm
3
Magnus Carlsson. 2002. Monads for incremental computing. SIGPLAN Not. 37, 9
(September 2002), 26–35. https://doi.org/10.1145/583852.581482
336
Chapter 10 The Reactive Programming Paradigm
collection view or table view layout. In this situation, it may be too costly to
lay out the entire view for a small state change, and instead, we will want to
ensure only an update to the affected area of the UI is updated.
Implementing incremental state updates requires that the runtime
understands the structure of the dependency graph. Specifically, the
runtime needs the ability to answer the following questions:
Topological Sorting
With topological sorting, the program gives each node a height and uses
this height and a minimum heap to answer our questions. It works as
follows:
337
Chapter 10 The Reactive Programming Paradigm
338
Chapter 10 The Reactive Programming Paradigm
4
Brian Lonsdorf (2015). Professor Frisby’s Mostly Adequate Guide to Functional
Programming.
5
Spuler, D.A., & Sajeev, A.S. (1994). Compiler Detection of Function Call Side
Effects. Informatica (Slovenia), 18.
339
Chapter 10 The Reactive Programming Paradigm
6
Cooper, G. H., & Krishnamurthi, S. (2006). Embedding dynamic dataflow in
a call-by-value language. Retrieved March 6, 2023, from https://cs.brown.
edu/~sk/Publications/Papers/Published/ck-frtime/
340
Chapter 10 The Reactive Programming Paradigm
7
Conal Elliott (2009). Push-pull functional reactive programming. In Haskell
Symposium.
341
Chapter 10 The Reactive Programming Paradigm
1. Monoids
2. Functors
3. Monads
Category Theory
Category theory is a branch of mathematics covering many key concepts in
functional programming. A category is an algebraic structure that models
objects and their relationships with each other. The connections between
342
Chapter 10 The Reactive Programming Paradigm
343
Chapter 10 The Reactive Programming Paradigm
Functors
Following basic category theory, we can discuss functors. A functor f
is a transformation between two categories, a and b. A functor can be
expressed as f: a → b. F must map every object and arrow from a to b.
In layman’s terms, a functor is anything that can be mapped over (most
commonly a list).
An example of a functor in code is the map function which in Swift
applies to any generic data structure, and for collections such as a list, we
can efficiently utilize the built-in map function.
344
Chapter 10 The Reactive Programming Paradigm
Monoid
In computer science, a monoid is a set, a binary operation, and an element
of the set with the following rules:
345
Chapter 10 The Reactive Programming Paradigm
Monads
In terms of category theory, a monad is a monoid in the category of
endofunctors,8 where an endofunctor is a functor that maps a category
back to that same category. This is a complex way of saying that a monad
is essentially a wrapper around a value, such as a promise or a result type.
In Swift, we have a built-in monadic type with optionals. However, it is
also common for engineers to create their own type, such as a wrapped
result type.
enum Result<WrappedType> {
case something(WrappedType)
case nothing // potentially an error
}
8
MacLane, S. (1971). Categories for the Working Mathematician. New York:
Springer-Verlag.
346
Chapter 10 The Reactive Programming Paradigm
monad), unlike the basic map function. In the following code sample, we
see the output produces null values with the map function, but these are
correctly filtered out when utilizing the compact map function.
347
Chapter 10 The Reactive Programming Paradigm
Combine Behaviors
The Combine framework provides many behaviors. The most commonly
thought of when thinking in terms of FRP concepts we defined earlier are
functors and monads.
348
Chapter 10 The Reactive Programming Paradigm
Functor
Mapping and filtering are both examples of functors and are concepts
we have used in previous architecture chapters as part of our Combine
pipelines to structure data. Another example in the Combine framework
utilized in our MVVM example is the remove duplicates function.
Figure 10-11 outlines the remove duplicates function as a marble diagram.
Monad
In addition to utilizing functors, Combine uses monads via the Any
publisher type system, as seen in our previous architecture examples
where we define our publisher.
AnyPublisher<[ModelProto], Error>
349
Chapter 10 The Reactive Programming Paradigm
paradigm, the exact internals of the framework are less critical than
the overall concepts. In the next section, we will review application
architecture utilizing the tenets of reactive programming.
Application Architecture
Application architecture is an ever-evolving process, and there is never
one correct answer. It is essential to consider your specific application and
its specific requirements. When considering the application data flow as
part of the architecture process, we want to map out the flows carefully.
One could be specific to a feature or portion of the application, similar to
Figure 10-2. This diagram level would be essential for a feature owner to
create. It provides low-level granularity about the specifics of the feature
without too much context into the broader application. For more senior
engineers who are more interested in defining the application’s best
practices and overall architecture, these diagrams will start to reflect a
comprehensive view of the high-level application flow while leaving details
for other feature owners.
As an example, we will go through our Photo Stream application to
provide a potential overall architecture diagram, including the reactive
programming paradigm. We will start as if we are a lead architect
architecting the design of the complete system and then break it down into
what senior engineers may focus on specifically.
Application Overview
As the lead application architect, it is your goal to understand the entire
application data flow end to end and be able to provide guidance on
best practices as well as design a similar scope application. We need to
understand the data flow at a high level to do this for our Photo Stream
application.
350
Chapter 10 The Reactive Programming Paradigm
351
Chapter 10 The Reactive Programming Paradigm
352
Chapter 10 The Reactive Programming Paradigm
353
Chapter 10 The Reactive Programming Paradigm
Area Owner
The next level down from being responsible for designing the overall
application is owning a specific area. While there are many areas to own,
including the data presentation layer, which would consist of owning the
significant UI interactions and understanding the application architecture
of the UI. The data presentation layer could also further break down into
portions. For example, owning the Photo Stream section and another
engineer and team (potentially multiple teams) would own a different
flow, like onboarding.
Utilizing what we have learned in this chapter about incremental
computing, we will focus on another large area of the application, the task
handler, instead of focusing on the UI. The task handler creates deltas
based on updates that could include commenting on a photo, liking a
photo, or adding a photo to the Photo Stream. These deltas are given to
the task handler to process appropriately. The task handler, as defined
in Figure 10-12, is responsible for coordinating optimistic state updates
and network-backed state updates. Figure 10-13 outlines a potential
architecture for such a task handler.
354
Chapter 10 The Reactive Programming Paradigm
355
Chapter 10 The Reactive Programming Paradigm
While the mutation manager is just one small area of the overall
application, it in and of itself is a large complex area that could support
multiple engineers. Another example of a portion of the application
we have not even touched on is that different types of content or media
could have different relative priorities. To implement priority ordering for
execution, our task executor could implement different queues.
Feature Owner
Stepping down a level to a senior engineer, you are most likely a feature
owner. The high-level principal engineer’s diagrams and your area
lead (sometimes a staff engineer) will help to guide your own feature
development and architecture inside the broader system. More specific
and targeted diagrams like the reaction diagram in Figure 10-2 are
necessary. Figure 10-2 explains the specific game feature, which could fit
into a broader application. In the context of our Photo Stream application,
this could be that you own the Photo Stream layout or several more minor
features like reactions and comments inside the view.
356
Chapter 10 The Reactive Programming Paradigm
357
Chapter 10 The Reactive Programming Paradigm
358
Chapter 10 The Reactive Programming Paradigm
Summary
We have covered a lot of ground in this chapter, from defining the
fundamentals of reactive programming to layering in functional concepts
and reviewing how we can utilize these concepts to architect iOS
applications. In the architecture review section, we broke down several
portions of our Photo Stream application and put them in the context of
different engineers by level and role.
Notice how we touched on Combine but did not focus on the actual
Combine implementation and syntax because the syntax used can change with
the framework. More important is understanding the ideas behind reactive
programming, how to layer in functional concepts, and how to architect an
entire application system regarding a reactive data flow. By understanding the
concepts of FRP and reactive programming, you can apply them to architect a
system for any number of applicable frameworks, not just Combine.
359
Chapter 10 The Reactive Programming Paradigm
Further Learning
1. Reactive Imperative Programming with
Dataflow Constraints: https://arxiv.org/
pdf/1104.2293.pdf
360
PART III
Approaching
Application Design
At Scale
CHAPTER 11
System Design
Process
Overview
This chapter marks the beginning of Part 3. Thus far, we have reviewed the
underlying iOS fundamentals (Part 1) and techniques to build scalable
applications (Part 2). However, being able to write quality code and
architect applications well is only a portion of what is necessary to deliver
valuable software in a large company. In this environment, engineering
and business teams can have competing priorities causing conflict and
adding risk to project success. Additionally, engineering teams themselves
can have competing priorities and conflicting launches. These added risk
factors result in significant cross-functional communication requirements,
resulting in additional planning and launch scheduling requirements.
To manage the added complexity in the planning and execution
phases, we will define a framework (software development life cycle)
and the critical soft skills necessary for the proper implementation of
the framework. The rest of Part 3 will dig more into individual skills and
competencies required for leading projects so that you can combine your
technical skills with leadership and soft skills required for further success.
364
Chapter 11 System Design Process
Plan
The planning phase includes tasks geared toward requirements gathering
and staffing. Engineers need to assist in
365
Chapter 11 System Design Process
366
Chapter 11 System Design Process
Strategy Review
The strategy review can take different names. Regardless of the term, its
creation is led by the business group, and its purpose is to communicate to
leadership the cost-benefit analysis, upside potential, and project timeline.
Engineer input is vital to understand the timeline and goals in the short
term. Additionally, tech lead input is required to understand the broader
engineering strategy long term and what engineering investments are
needed to support the continued long-term project goals.
367
Chapter 11 System Design Process
Road Map
The road map is more detailed and immediate than the long-range
planning document and defines short-term task-level items, and leads to
an intermediate goal in the long-range planning document. The road map
should have clear success criteria with metric-backed goals and a clear
understanding of the software necessary to reach them.
368
Chapter 11 System Design Process
1
https://docs.google.com/spreadsheets/d/1zlx3RuidNOW40Zf7gh07p2SqoR53U
ngv9JFT-PhHwxI/edit#gid=184965050
369
Chapter 11 System Design Process
370
Chapter 11 System Design Process
Figure 11-3. Differences in planning for early stage and more mature
larger companies
Design
In the design phase, software developers review the engineering
requirements and identify the best solutions to create the required
software. Engineers will consider integrating preexisting modules and
identifying libraries and frameworks that could speed the implementation
process. They will look at how to best integrate the new software into the
organization’s infrastructure. In both the design and planning phases,
it is essential for tech leads to reach alignment with other engineering
stakeholders on the design’s approach. Here is where architecture
documents shine and where the knowledge from Part 2 of this book is best
applied.
371
Chapter 11 System Design Process
Note The plan and design phases are critical for a senior tech
lead and are where the tech lead’s domain expertise is most
important. The tech lead sets the team up for success during the
implementation phase by leading the planning and architecture
design. As a tech lead, you cannot do the entire implementation
alone. Still, by planning and designing well, you can give clear
guidance to other team members, enabling them to execute
against their priorities and deliver quality software during the
implementation phase.
372
Chapter 11 System Design Process
Implement
After agreeing on the planning, timeline, and goals, the time comes
to implement the necessary engineering changes. During the
implementation phase, if the team is well positioned for success, the tech
lead can take a backseat role and focus on reviewing code, writing some
code, and helping mentor the rest of the team to meet the objectives.
By positioning the team well, the tech lead enables execution without
requiring heavy involvement. During this extra time, the tech lead
can focus on shoring up the launch path and thinking about future
opportunities to drive the project ahead. The primary skills necessary to
succeed in the implementation phase are related to Part 1 of this book,
where we discuss core software engineering competencies. Here, we have
already defined the design and need to write correct code leading to a
high-quality implementation. The tech lead can help enforce this standard
by holding a high standard in code reviews.
In the implementation phase, tracking progress toward goals and
implementing incremental product testing are critical. By implementing
incremental product testing, the team can avoid surprises during
deployment. Depending on the team’s implementation of the SDLC, there
are different ways of tracking development progress. Typically there needs
to be some way of analyzing the requirements to identify smaller coding
tasks that add up to the final result and monitoring their completion
against dates on the road map. Depending on the company, this may fall
more on the product management team or the engineering tech lead.
Either way, the tech lead must be involved in tracking progress and helping
speed up the team’s expertise.
Additionally, suppose the team starts to fall behind. In that case, it is
the responsibility of the tech lead to identify the risk, raise it to relevant
stakeholders, and present alternative engineering solutions to solve the
problem. Ways to speed up the project include increased input from
subject matter experts, more engineering resources, or cutting scope.
373
Chapter 11 System Design Process
Test
The test phase should execute in parallel with the development phase.
As engineers write new code and complete features, the individual
developers are responsible for writing unit and integration tests. Upon
feature completion, more holistic testing should occur. Depending on
the company setup, this can include internal bug bashes and dogfooding
sessions. Additionally, quality assurance testers can be leveraged to
continuously check critical features for any bugs and ensure that the
application meets specifications defined in the planning phase.
Continuous product testing on the eventual deployment infrastructure
stack is critical to ensure all features work as expected for end users. To
accomplish this, on iOS, teams can utilize tools like Visual Studio App
Center and TestFlight to distribute builds. However, this goes beyond just
the iOS application. The iOS application will most likely interact with the
network, so the associated API and back-end teams must ensure they
properly set up their code to support the new features. One way to do so
is to use different developer flags controlled by configuration to turn the
feature on for only a subset of testing users. To widen the number of users
on the new feature set and gather more bug reports, some companies
institute corporate dogfooding where all employees utilize internal builds
of the product to test the latest features.
374
Chapter 11 System Design Process
Deploy
Software deployment and feature release are another stage requiring
much tech lead input. The release schedule, timing, and strategy may
differ depending on the team’s goals and specific projects. As a tech lead,
you must define this with the relevant stakeholders. For example, suppose
you are leading a large project migration. In that case, there may be set
milestones based on necessary feature sets, or if you work on a metric-
based team, specific measurement weeks may require project releases to
align with them.
Teams should not wait to deploy changes – getting an early and
continuous signal is critical to ensure regressions and bugs are found
sooner rather than later. This way, aligning the deployment pipeline with
the eventual release process via build configurations is vital. Additionally,
this unblocks getting continuous feedback from the ongoing test and
implementation workstreams.
375
Chapter 11 System Design Process
Maintain
In the maintenance phase, the team fixes bugs, resolves customer issues,
and handles any subsequent software changes. In addition, the team
monitors overall system performance via metric dashboards and logging.
The team will monitor the user experience to identify new ways to improve
the existing software for the next planning phase. It is helpful to buffer
additional time during planning to ensure developers have time to address
bugs found in the deployment or maintenance phase.
Given how the different phases interact, we can rewrite Figure 11-1 to
represent this in Figure 11-4. Here, we draw additional lines representing
the development cycles between the implementation, test, and
deployment phases. We have highlighted with light gray the key steps tech
leads should concern themselves with, and dotted lines represent links
between the implementation, design, and plan phases as tech leads should
continuously look to evaluate the planning and design phases.
376
Chapter 11 System Design Process
SDLC Models
In the previous section, we discussed the overall SDLC and mentioned that
different models describe some implementation steps more specifically.
While it is not necessary to follow the exact steps of one of these specific
models, they do represent commonly used implementations. Most
models are geared to optimize a part of the SDLC and are created to help
organizations implement the SDLC.
Waterfall
The waterfall model organizes the SDLC phases sequentially, and each
new step depends on the outcome of the previous stage. Conceptually, the
SDLC steps flow from one to the next, like a waterfall. Figure 11-5 outlines
the steps of the SDLC organized for the waterfall method.
377
Chapter 11 System Design Process
Key Tenets
Three main principles guide the waterfall method:
3. A sequential structure
Spiral
The spiral model combines an iterative approach to the SDLC with
the waterfall model’s linear sequential flow to prioritize risk analysis
and iteratively ship software. The spiral model ensures that software is
gradually released and improved in each iteration. Each iteration also
involves building a prototype. Figure 11-6 outlines a spiral model iteration
applied to the SDLC.
378
Chapter 11 System Design Process
Key Tenets
2
Boehm, B (July 2000). “Spiral Development: Experience, Principles,
and Refinements.” Special Report. Software Engineering Institute.
CMU/SEI-2000-SR-008.
379
Chapter 11 System Design Process
Agile
The Agile method arranges the SDLC phases into several development
cycles. The team iterates through the phases rapidly, delivering only small,
incremental software changes in each cycle. They continuously evaluate
requirements, plans, and results to respond quickly to change. The Agile
model is iterative and incremental, making it more efficient than other
process models. Figure 11-7 illustrates a cycle in the Agile model. The
overall project will consist of many of these cycles.
380
Chapter 11 System Design Process
Key Tenets
381
Chapter 11 System Design Process
Additionally, the Agile methodology does not always scale well due
to the communication overhead, lack of documentation, and complex
process requirements. Without clear documentation, aligning with
stakeholders in large environments where good documentation and
design documents are critical tools in driving alignment is difficult.
Lastly, the Agile methodology defines many principles and practices
that are rigid and difficult to follow, especially in a large company where
many stakeholders would be necessary. This causes most teams only to
adopt a portion of the Agile method.
1. Pair programming
382
Chapter 11 System Design Process
Key Tenets
Methods Review
Across all of these methods are a few key tenets that apply well to large
applications at scale. Embodying these tenets and learning skills to address
them allow you to lead projects successfully across any methodology. A
tech lead plays a crucial role in project success and ensuring the team
meets its goals. We can see some recurring themes across all of these
approaches, namely:
1. A focus on documentation and planning helps
projects succeed at massive companies.
2. Flexible iterations with iterative planned releases
help teams ship software and detect bugs.
3. Communication across stakeholders is critical
across all methodologies.
4. Testing is vital to hold a high bar for software
quality.
Being able to lead across these areas is how a TL can build skills for
success in different environments. Since testing is a separate stage in
the SDLC, we will focus on skills related to effective planning and as the
critical skills required in this chapter.
384
Chapter 11 System Design Process
385
Chapter 11 System Design Process
Large-scale projects are complex and can cross multiple teams, making
organizing and leading product rollouts complicated. All experiences must
meet the launch timeline and must function together. To this end, it is
essential to have experienced tech lead input on the proper launch path.
By leading the beginning and end of the project in detail, the senior
tech lead allows their team to grow and develop via mentorship in the
execution phases. This also provides the tech lead time to think ahead on
future product direction and ensure that the overall project stays on track.
The broad SDLC and understanding of the tech lead’s pivotal role
transcend any specific formal steps. Memorizing and following a particular
design process strictly without truly understanding the value a strong
tech lead provides is of little importance. Instead, the value is in knowing
and developing the skills to communicate and lead a development team.
The SDLC and methods such as Agile provide heuristics, similar to a
software design pattern on how to best lead based on hundreds of years
of combined developer experience. By being flexible and focusing on
the skills necessary to lead, you can quickly adapt to any methodology.
Here, we break these skills down into individual items. Later in Chapters
16 and 17, we combine these personal skills with broader leadership
tenets for large teams and practical examples. By first breaking down
leadership skills to the individual level, we are approaching people skills
and leadership development like we did with technical concepts, where
the individual skills serve as the building blocks to a more comprehensive
team leadership.
A Skill-Focused View
Regardless of the methodology, the end goal is delivering the software and
driving business value. As a senior tech lead, your job is explicitly to lead
from a technical aspect and be able to
386
Chapter 11 System Design Process
3. Communicate clearly
Problem Navigation
Problem navigation involves demonstrating the ability to organize the
problem space, constraints, and potential solutions in a systematic
manner. To accomplish this, you must ask questions to reduce ambiguity
and systematically target the most critical problems. To do so, it is essential
to proactively minimize ambiguity by asking clarifying questions (e.g., how
many friends can a user have? How big is the data set?). This information
leads to exploration into the most critical problem areas and guides
significant parts of the design process and solution requirements.
The initial questions should be helping you to better understand what
engineering abstractions could be used and any potential pitfalls. In some
cases, business partners may be unaware of the constraints from existing
infrastructure or even privacy problems. It is essential to work with them
when crafting a workable solution. Additionally, defining the requirements
for quantitative analysis is integral to navigating the problem space for goal
setting. With a quantitative framework, it is easier to create goals consistently.
Moreover, an awareness of the product from an end-user perspective
is necessary to better understand the business perspective and holistically
drive toward the shared business vision.
Solution Design
Once the problem space is clarified, it is necessary to design a working
solution for the complete problem and outline essential portions of
the overall design in detail. You must consider the broader context in
387
Chapter 11 System Design Process
the design and keep scale and multiple development teams in mind.
For example, if your team is building the feature, other teams may be
responsible for a shared web or infrastructure layer that you depend on for
feature development. Involving them early and working to call out their
portion of the solution are essential when driving toward a shared goal.
During solution design, the tech lead must create an effective working
solution that addresses multiple critical aspects of the problem in an
easy-to-understand manner. The design should consider the required
scalability across large amounts of data and users (e.g., accounting for
large-scale data in their design, such as syncing with error correction). The
working solution should include a detailed rollout plan, including rollout
stages, metric-based evaluation, and success criteria.
The tech leads must have experience developing products and the
technical ability to architect complex solutions involving design patterns
and architectural best practices. The level of technical expertise requires
tech leads to articulate dependencies and trade-offs in the solution and
identify challenging aspects of the problem, including foreseeing and
mitigating potential failure points.
When expressing these technical challenges and foreseen points of failure,
it is essential to articulate them as trade-offs, for example, understanding the
weaknesses of a standard design or architectural patterns (e.g., scaling up
engineers on the project, amount of code, or amount of users). Tech leads
should be able to leverage various experiences to illustrate points of failure.
Beyond technical skills, it is also essential to understand how technical
decisions impact product behavior for different end-user populations. By
understanding the business constraints, you can work well with business
partners to drive technical solutions that support business value. Without
this connection, you risk having the technical solution diverge from the
business requirements creating a gap. To have this connection, frequently
connect with business partners to ensure UI and engineering designs are
applicable, functional, and achievable in the required time frame and meet
product requirements.
388
Chapter 11 System Design Process
Communication
As a software engineer leader, having the skills to navigate difficult
conversations is imperative. Sometimes, the business and engineering
functions will disagree on technical decisions due to business constraints.
Even within the technical space, disagreements commonly stem from
divergent technical approaches. Navigating these conversations and
driving decisions is a crucial skill for any tech lead. We can systematically
manage complex and potentially contentious discussions by building a
framework. Our framework will work toward four fundamental principles:
Communication Framework
Understand Everyone’s Point of View
Overall it is essential to address and align on the problems causing
contention between you and the other person. Whether business related
or technical, you can gain a more in-depth understanding by digging
deeper to understand the details and driving factors behind the person’s
opinion. Understanding the other person’s point of view is essential when
structuring the rest of the framework. This understanding allows us to
provide mutually beneficial solutions and align on the crucial data to
further guide the conversation.
389
Chapter 11 System Design Process
For example, say you work on a team that prioritizes building new
user engagement features to optimize for increased daily active users and
session length. You also know from previous analysis that application
startup performance positively impacts user session length. Your business
partners want to quickly release a new feature to keep up with their
competition. However, it requires building on a legacy module with a high
application startup cost.
Additionally, the legacy module has limited user coverage and
no documentation, making development difficult and error-prone.
From an engineering perspective, refactoring the legacy component
improves development, startup performance, and ease of future feature
development; however, this will delay the release of the high-priority
business feature.
Your business partner is very concerned and does not want to take
on any risk to the project or delay the launch for any reason. Given the
cross-cutting concerns, you must align with the business stakeholders
on the best path forward. To do so, as outlined in our framework, we
390
Chapter 11 System Design Process
391
Chapter 11 System Design Process
We can phrase this as two solutions: one from the business perspective
and the other from the engineering perspective. By presenting both
options, you can solicit feedback from all relevant stakeholders and drive
the conclusion. When providing options, it is essential to select one as the
recommended option; this further frames the conversation in a solution-
oriented manner. For all solutions, it is vital to
1. List out the pros and cons of each so all parties can
align on the relative risks.
392
Chapter 11 System Design Process
393
Chapter 11 System Design Process
Communicating Risk
Lastly, it is always important to communicate any risks to the project
timeline. No one likes surprises, and by sharing any development risk
earlier, you mitigate the potential for long-term unscheduled project
delays. By flagging risks and presenting potential alternative paths, you
also give your stakeholders options on how to proceed and help gain buy-
in on the decisions made. Providing options improves stakeholder buy-in
because giving them a choice makes them feel part of the decision-making
process and the product’s future success. Now that we have defined our
communication framework, we can apply this to both technical and
business communication.
394
Chapter 11 System Design Process
Technical Communication
1. Technical communication requires articulating
technical ideas, viewpoints, and vision. Technically
solid communication necessitates the ability to
reason in logical and structured ways as they relate
to engineering. It is helpful to utilize a data-driven
scientific approach.
3. Formulating a hypothesis.
395
Chapter 11 System Design Process
396
Chapter 11 System Design Process
To resolve this, you can involve relevant stakeholders and align across
company values. For example, by developing the feature now, the project
is aligned with the value of “moving fast.” However, they can counter
and say you are not “putting the user experience first” by delivering
subpar ads. Because of the fundamental misalignment, the conflict may
not be resolvable at your team level; it may be necessary to escalate to a
leadership role between the two organizations. Do not be afraid to raise
these issues; delaying in this situation can cause further problems.
397
Chapter 11 System Design Process
398
Chapter 11 System Design Process
S: Specific
An actionable goal requires specificity. A specific goal answers the
following questions:
1. What needs to be accomplished?
2. Who is responsible for accomplishing the goal?
3. What steps need to be taken to achieve the goal?
For example, our specific goal can be to grow the number of monthly
users of our mobile application by optimizing our onboarding flow and
creating targeted social media campaigns.
M: Measurable
Part of a specific goal is its quantifiability. By aligning on success criteria
measurably, teams can track progress and know when the work is complete.
To make our preceding goal more measurable, we can redefine our goal to
include increasing our mobile application monthly users by 1,000.
A: Achievable
For a realistic goal, we must ask ourselves: Is your objective something
your team can reasonably accomplish?
On further inspection and data analysis, we may realize that increasing
the number of monthly users of our mobile application by 1,000 is a 25%
increase, which in one quarter is unreasonable. Instead, optimizing our
conversion flow by 10% is more feasible.
Ensuring the achievability of your goal is much easier when you are
the one setting it. However, that is not always the case. When goals are
communicated top-down, it is necessary to communicate any restraints
you may be working under that make the goal difficult or impossible to
achieve. Even if you cannot change the end goal, at least you can make
your position (and any potential blockers) known up front.
399
Chapter 11 System Design Process
R: Relevant
Understanding the broader picture and business context is essential
to ensure your goal is relevant. Here, we know our goal is appropriate
because growing the number of monthly users helps us to increase our
profitability since we will have more users to display advertisements to.
T: Time-Bound
To correctly measure success, we need to understand the time required
to reach the goal and when we can start working to achieve the goal.
Additionally time-bound goals allow everyone to track progress. Thus, to
finalize our goal, we need to make it time-bound.
We will grow the number of monthly users of our mobile application
by 10% within Q1 of 2022. This will be accomplished by optimizing our
onboarding flow and creating targeted social media campaigns beginning
in February of this year. Increasing our user base will increase our
advertisement revenue, a key success metric for our business.
Note Using the SMART framework helps you succeed in setting and
attaining big and small goals.
400
Chapter 11 System Design Process
401
Chapter 11 System Design Process
402
Chapter 11 System Design Process
403
Chapter 11 System Design Process
404
Chapter 11 System Design Process
Summary
We started this chapter by discussing the software development life cycle
and the different stages. We then presented some standard methods
of implementing these steps and keys to success. Zooming out, we saw
a lot of overlap in these critical tenets and how, regardless of the exact
methodology used, the skills required to take a project from ideation to
production and continue to add value long term come down to
1. Navigate the technical problem space
2. Design and lead the technical solution
3. Communicate clearly
4. Set up appropriate goals
405
Chapter 11 System Design Process
Further Learning
1. The Mythical Man-Month: Engineering sizing and
challenges
406
Chapter 11 System Design Process
407
CHAPTER 12
Testability
Overview
Following the SDLC, we must begin testing after we start implementing
the feature. Initial testing may involve unit tests specific to the logic
built. As the feature develops and the launch date is closer, it is crucial
to begin implementing more rigorous forms of testing. These should
include integration tests and manual testing steps to ensure the feature
development work and continue to work as more features are developed.
Without comprehensive testing, building confidence in feature correctness
is difficult, which poses a real risk to launching the feature.
Why Test
As software engineers, we aim to iterate on and complete features
contributing to company goals as quickly as possible. To continuously
deliver quality code, it is necessary to ensure the code is easy to
understand and bug-free. Testing encourages both and promotes overall
quality code. When testing best practices are applied across the code base,
they promote
2. Fewer bugs
410
Chapter 12 Testability
411
Chapter 12 Testability
Unit Testing
Unit testing is the most basic testing type. Unit testing aims to test a
specific unit of code (where a unit is loosely defined). For any feature
developed, essential logic should have associated unit testing. Not all code
should require unit tests. Unit tests should cover all possible code paths,
not just the success path. Additionally, we only want our tests to fail when
the class under test changes, not when any changes occur to underlying
dependencies. We do not want our tests to fail when
Unit tests are most critical for logically complex portions of code.
Well-written unit tests express the different use cases of the classes and
define success and failure criteria, which helps new engineers understand
those code areas. If unit tests fail due to unseen dependencies, engineers
will lose confidence in the testing system and start skipping tests and
ignoring failures. We want to test a class WrappedUserSettings that parses
some JSON, writes the results to UserDefaults, and updates an existing
dictionary with new values.
412
Chapter 12 Testability
class WrappedUserSettings {
static func updateSettings(fromJSON json: String) {
let parser = Parser.shared
let defaults = UserDefaults.standard
guard var newSettings = parser.parse(
json: json) else { return }
if let settings = defaults.object(
forKey: "user_data") as? [String:String] {
newSettings.merging(settings) {
(new, _) in new
}
}
defaults.set(newSettings, forKey: "user_data")
}
}
import XCTest
class TestRunner: XCTestCase {
func testUpdateSettings() {
let json = "{\"name\": \"steve\"}"
WrappedUserSettings.updateSettings(fromJSON:json)
let expected = ["name": "steve"]
XCTAssertEqual(
expected,
UserDefaults.standard.object(
forKey: "user_data") as? [String : String],
"User defaults should contain the updated name"
)
}
}
TestRunner.defaultTestSuite.run()
413
Chapter 12 Testability
Now, our test passes and will detect when a bug is added to the
WrappedUserSettings class. However, it will fail at the wrong times if the
simulator starts modifying the UserDefaults dictionary or if our JSON
parser were to contain a bug. If our test was an integration test, we would
want it to detect bugs in the JSON parser, but this is a unit test, so we only
want to see failures in our WrappedUserSettings class. We can illustrate
this by adding an additional test. Here, we have a test that only passes since
UserDefaults continues to store the already-added value for the name.
414
Chapter 12 Testability
protocol ParserProto {
func parse(json: String) -> [String: String]?
}
class WrappedUserSettingsUpdated {
private let parser: ParserProto
private let defaults: UserDefaults
init(
jsonParser: ParserProto,
standard: UserDefaults
) {
self.parser = jsonParser
self.defaults = standard
}
415
Chapter 12 Testability
(new, _) in new
}
}
defaults.set(newSettings, forKey: "user_data")
}
}
protocol UserDefaultsProto {
func object(forKey defaultName: String) -> Any?
func set(_ value: Any?, forKey defaultName: String)
}
Lastly, we must update all our call sites to utilize our new
protocol, such as
We can proceed with our tests now that we have updated our code.
First, we will create the necessary mocks to ensure we have fine-grained
control over our unit tests. First, the parser:
416
Chapter 12 Testability
417
Chapter 12 Testability
func testUpdateSettingsFails() {
let json = "{\"name\": \"steve\"}"
mockParser.parseResult = ["name": "billy bob"]
userSettings.updateSettings(fromJSON:json)
let expected = ["name": "steve"]
XCTAssertNotEqual(
expected,
userDefaults.object(
forKey: "user_data") as? [String : String],
"User defaults should contain the updated name"
)
}
418
Chapter 12 Testability
419
Chapter 12 Testability
420
Chapter 12 Testability
know their immediate dependencies.1 Unit tests that rely on other classes
make the code brittle and testing complex. To illustrate this, we have the
following delegate method that creates a Photo Stream unit and adds the
ability to react to a story. Here, we both instantiate the reaction object and
execute the like method.
func didPressLike(
with reactable: ReactablePhotoStreamStory
) {
let reactionManager = ReactionManager(
userId: currentUser
)
reactionManager.likeStory(reactable)
}
Instead, we can modify the method and surrounding class so that the
method only does one thing.
init(reactionManager: ReactionManager) {
self.reactionManager = reactionManager
}
func didPressLike(
with reactable: ReactablePhotoStreamStory
) {
reactionManager.likeStory(reactable)
}
1
K. J. Lieberherr and I. M. Holland, “Assuring good style for object-oriented
programs,” in IEEE Software, vol. 6, no. 5, pp. 38–48, Sept. 1989, doi:
10.1109/52.35588.
421
Chapter 12 Testability
Setter Injection
In this book, we have used constructor injection throughout to inject our
dependencies. Another option is setter injection; with setter injection, an
object exposes setter methods to override parts of the object’s behavior. We
can change our class earlier to remove the initializer and utilize properties
instead. Since the property is public, we can modify the dependency
externally. Alternatively, one could create a public setter and private
property.
class WrappedUserSettingsUpdated {
var userDefaults: UserDefaultsProtocol = UserDefaults.
standard
}
Setter injection does require that every test knows how to override all
the behaviors of each dependency of the class under test. For example,
a dependency may access a database today, and you can override the
behavior in a test environment. Tomorrow, that same dependency may
access the network to download information. This change may not cause
the test to fail initially. Eventually, if run in an environment with no
network access (or unreliable network access), the test may begin to fail.
This behavior creates a brittle test environment prone to flaky tests.
The fragile test environment created by setter injection is magnified
over time in large-scale applications as more functionality is added. For
example, an engineer may modify an object to include network access and
cause failures in unrelated parts of the code base requiring a complicated
debugging effort in unfamiliar code. This is why we suggest using
constructor injection.
422
Chapter 12 Testability
Integration Testing
Unit testing is an excellent way to ensure that individual code units work
and provide living documentation of the program’s internals. However, it
does not account for complex interactions between components. Unit tests
have limited value in ensuring the overall application functions correctly.
Ensuring flows work together is the job of integration testing. While more
expensive to write, integration tests are excellent for ensuring application
correctness.
The general purpose of integration testing is to test the code without
mocking dependencies to test how different components interact
and ensure they work together to deliver the end user the correct
result. Typically, at some level, mocks still exist. For example, a basic
integration test could involve purposely changing our earlier unit test to
use the underlying UserDefaults implementation. By not mocking the
UserDefaults, we can ensure that end to end our system functions.
Integration tests for iOS can go beyond testing iOS-level components
like UserDefaults and expand to cover network or other peripheral
interactions. An integration test could consume live data from the server
and test the application interactions to ensure the UI is appropriately
displayed. Some teams mock data at the application’s data layer instead
of receiving live data from the server. This can help prevent unintended
changes and reduces third-order effects like a poor network connection.
The level of mocking will largely depend on the setup of the application
and build pipeline; there is no one answer for how to write integration
tests. When considering whether to utilize live data or mocks, it is essential
to consider a few trade-offs:
423
Chapter 12 Testability
424
Chapter 12 Testability
2
https://engineering.fb.com/2018/05/02/developer-tools/
sapienz-intelligent-automated-software-testing-at-scale/
3
P. McMinn, “Search-Based Software Testing: Past, Present and Future,” 2011 IEEE
Fourth International Conference on Software Testing, Verification and Validation
Workshops, Berlin, Germany, 2011, pp. 153–163, doi: 10.1109/ICSTW.2011.100.
4
https://engineering.fb.com/2018/05/02/developer-tools/
sapienz-intelligent-automated-software-testing-at-scale/
425
Chapter 12 Testability
Contract Testing
A contract test is a style of integration testing that tests the boundary
of an external service verifying that it meets the contract expected by a
consuming service.5 Since these tests are based on specific changes to the
API contract between server and client, they do not necessarily need to
be run at the same cadence as other tests, potentially only once per day
or when specific contract changes are detected. Contract testing helps to
answer the following questions:
However, contract tests will not answer the question: Does the provider
do the right thing with the request? This is the responsibility of other
functional integration tests.
A failure in a contract test should not necessarily break the build the
same way a functional integration test would. It should, however, trigger
a task to the appropriate owner to further triage the inconsistency and
address the gap. Fixing a broken contract test may involve updating the
tests and code to achieve a consistent result. It will likely also start a
conversation between service owners to discuss the change and ensure
a mutual understanding of the downstream effects of the change. If
the contract test is run before landing changes, it should block land to
guarantee that service owners discuss any breaking changes and address
them before landing the code.
5
https://martinfowler.com/bliki/ContractTest.html
426
Chapter 12 Testability
One option for contract testing is via Pacts. Pacts are a consumer-
driven contract testing strategy that reduces the chances of unexpected
contract breaks. Consumer Pact tests assume that the provider returns the
expected response for the request and attempts to answer the question:
Does the consumer code correctly generate the request and handle the
desired response? Figure 12-1 outlines the Pact testing flow.
6
https://docs.pact.io/getting_started/how_pact_works
7
https://docs.pact.io/getting_started/how_pact_works
427
Chapter 12 Testability
GraphQL, you can still utilize contract testing via Pacts. This is because
GraphQL is just an abstraction over REST where requests are made via an
HTTP POST and queries are formatted as stringified JSON within a query
property of the request.8
An alternative to Pact testing’s client-driven approach is to use VCRs.
VCRs present a version of contract testing where the server-side HTTP
requests are recorded and replayed. With VCR testing, one must be careful
to avoid storing sensitive data in the VCR recordings. Additionally, VCRs
require re-recording when any changes are made, making them more
difficult to maintain. When a breaking change is made, it is difficult to
understand precisely which clients will break. VCR testing does allow for
easier external service testing since the results are pre-recorded and do
not rely on an active network connection. When evaluating a framework,
weighing the pros and cons of the different frameworks and choosing the
one that best suits your application’s needs are essential.
UI Testing
In iOS applications, the user interface and interactions are essential to
the function of the application. Hence, testing the user interface and
interactions is very important. Testing the application interface presents
several challenges. It is difficult to keep UI tests current, and they can take
longer to run, even when executed remotely. Running the tests remotely
and prioritizing running them on the most critical flows are necessary to
support UI testing at scale. In addition to UI integration tests discussed
earlier, we can leverage snapshot tests to provide UI coverage.
8
https://graphql.org/learn/queries/
428
Chapter 12 Testability
Snapshot Testing
Snapshot testing takes a screenshot “snapshot” view of the user interface
on different screens and compares them on a pixel regression basis.
Snapshot tests verify the appearance of views and help identify visual
regressions without tedious manual testing. Utilizing a distributed build
system where every run instantiates all snapshot test cases helps support
running many snapshot tests.
Manual Testing
While having a host of intelligent automated testing is essential, these
tools only partially replace manual testing. Manual testing provides a
gold standard for testing specific application flows and identifying errors.
Additionally, this book includes dogfooding and early releases as manual
testing. Dogfooding, having employees utilize beta applications before
shipping, and shipping an early release to only a subset of consumers
provide real-time manual testing feedback on the product before fully
releasing. Dogfooding can be built and distributed as part of the iOS build
system. At some intervals before submission for release to Apple, the build
can be sent to all employees for usage if bugs are detected; they can be sent
to team POCs for further triaging. Typically teams assign a rotating POC to
handle such issues and refer to this as an oncall rotation (we will discuss
the oncall pipeline and build system construction more in subsequent
chapters).
It is not always practical to have the entire company dogfood the
product. Your product may be geared toward construction companies,
and the application itself is of limited usage to employees daily. In that
case, organizing targeted dogfooding sessions before the feature release
is more important. By scheduling a block of time for the entire team, not
just engineers, to go through the project and explore the functionality, you
429
Chapter 12 Testability
can get early feedback on any issues and the overall feature design. You
can receive holistic feedback from different perspectives by involving other
business partners, such as product managers, designers, and others. To
expedite and focus the dogfooding session, provide any login credentials
for test accounts, dummy credit card numbers, or other necessary setup
steps that other nontechnical team members may struggle with prior to the
session. Additionally, by providing instructions on what feature is under
test and critical success criteria, you can help further drive the discussion.
Having a quality assurance (QA) team is another way to add more
manual application testing into the flow. QA testers receive clear,
actionable test plans from engineers and will execute many different
testing scenarios to understand and document bugs they find. Many QA
teams will also perform exploratory testing beyond the initial cases and
attempt to break the application, which is incredibly useful for building
a robust product. Testers should provide detailed reports of how to
reproduce the bug they find and a screen capture of what they viewed.
How much your company and application utilize automated tests vs.
manual tests is up for debate and dramatically changes depending on the
application and company culture. Automated testing provides a hands-off
approach that avoids time-intensive and potentially expensive contracts
with QA teams (or in-house staff ). However, automated testing does not
offer a real-world experience of using the application, and while tests
should be fast and accurate, this is not always the case. Relying solely on
automated tests may cause bugs to go unnoticed until right before or even
right after release. Moreover, complex automated integration tests can
become expensive to run due to device farm costs. By integrating manual
testing into the feature development and release process and automated
build pipeline, teams can take advantage of both automated testing and
rigorous manual testing.
Manual testing is not without problems; detailed test plans must be
maintained and updated; otherwise, QA resources spend time testing the
wrong thing. Additionally, exploratory testing may yield a lot of
430
Chapter 12 Testability
low-value bugs – or experiences that are not bugs at all but just odd edge
cases missing from the product specification delivered to the QA team.
Triaging these bug reports takes time away from engineers who prefer to
build features. By weighing the pros and cons, understanding the options
available, and carefully considering the application’s needs, you can make
a suitable investment in both manual and automated testing.
To assist in this, running a cost-benefit analysis on when bugs are
detected, by whom, how many bugs there are, and how much time
engineers spend manually testing features helps evaluate where to put future
testing investments. For example, if many bugs are found by engineers in
dogfooding sessions days before a feature release, then integrating manual
testing sooner is best. However, if many bugs are found in mostly older
features due to changes introduced in newer features, then more automated
integration and unit tests will help catch these. Of course, manual QA can
also perform long-term testing of key flows to ensure they are always correct.
431
Chapter 12 Testability
1. Account management
2. Payments flow
3. Onboarding flow
432
Chapter 12 Testability
433
Chapter 12 Testability
434
Chapter 12 Testability
Summary
Throughout this chapter, we covered testing in iOS applications. Testing
should not be a static step in the SDLC; instead, it should be performed
throughout as new features are developed and include testing on existing
features to avoid regressions. Here, we broke down testing into automated
testing and manual testing. To successfully test our application, we need
both automated tests to provide fast, actionable feedback and a way to
run many tests without huge human resource costs. However, they do not
address all situations (even with wins in AI-based automated UI tests), and
this is where manual testing shines.
Manual testing is critical for iOS applications because it allows them to
test their interaction with physical world components such as the camera
input (QR code recognition), document scanning, or augmented reality.
While it is possible to automate many of the tests, manual testing is still
required to fully understand the applications’ performance.
435
Chapter 12 Testability
Further Learning
1. Hands-On Mobile App Testing: A Guide for Mobile
Testers and Anyone Involved in the Mobile App
Business by Daniel Knott
436
CHAPTER 13
Performance
Overview
Application performance does not fit neatly into the SDLC; some portion
of performance via load testing could fit into the test phase. However,
managing overall application performance over time does not have a
designated step. Despite this, application performance is especially
important in larger applications with many existing users and features.
Too often, when engineers discuss application performance, they
only focus on application crashes and recommend using instruments
to debug further. However, application performance is more than that.
Application performance involves ensuring over the application’s life span;
it speedily renders the UI, handles poor network connectivity, and loads
quickly at application start. Performance becomes increasingly complex
as additional features are developed in large applications where overall
performance can degrade slowly with no apparent root cause.
most large companies utilize custom tools, we will focus on the classes of
performance problems and understanding performance metrics with less
focus on becoming a power user of a specific set of tools.
An additional aspect of performance is build performance. As an
application scales, the overall application size can become quite large, and
compiling the entire application for each run can become unmanageable.
Compiler optimizations are available to speed up build times. Additionally,
developers can make applications more modular so that they only need
to build a subset of the application instead of waiting for the entire
application to build. This chapter will only focus on end-user performance
metrics, not build performance.
438
Chapter 13 Performance
Key Concepts
This chapter will discuss critical metrics (commonly referred to as topline
metrics) to guide application performance tuning and measurement
tools to help find and address performance issues. Before proceeding,
439
Chapter 13 Performance
440
Chapter 13 Performance
T opline Metrics
These are the most critical metrics for the company. While sometimes
requiring significant underlying changes to show movement, topline
metrics are always essential for reporting and are the overall metrics
relied upon the most for accurate reporting for investor and shareholder
meetings. Topline metrics are typically related to revenue and user
engagement. However, they can be related to whatever drives business
growth and success.
Topline metrics also require diligent tracking and are the subject of
scrutiny across the higher echelons of the company. For example, in our
Photo Stream application, our main source of revenue is ads; for that, we
need a high number of daily and monthly active users. We can then break
down our topline metrics into revenue, CPMs (cost per mille, an advertiser
term for cost per 1000 impressions), and impressions. These provide the
company with an overall view of how effective ads are and the cost per ad.
In this way, ads teams and teams related to user engagement, such as
what content is displayed in the feed, will have to track their respective
metrics closely for communication with business partners. Additionally,
if any fluctuation is detected in these metrics, high-priority incidents will
be opened in partnership with business teams. From an engineering side,
these investigations will be a high priority and high visibility requiring
diligent investigations and retrospectives. These investigations must
account for all factors, including market dynamics and yearly trends,
including how holidays affect traffic. Many times these investigations will
be internal to the company.
However, there are publicly available retrospective documents for
many large public-facing incidents, such as GitHub’s outage, where
outdated information was served to consumers.1 For GitHub, since
1
https://github.blog/2018-10-30-oct21-post-incident-analysis/
441
Chapter 13 Performance
their service is paid for, they must track reliability and uptime as topline
metrics. Another example of this is from a company that prioritized
reliability, Cloudflare. Cloudflare tracks all incidents-related reports2 in a
clear format so clients understand the behavior of their integrations. The
reports include
1. Resolution status
2. Monitoring
3. Latest status update
4. How the issue was identified
5. Investigation steps3
I ntermediate Metrics
Since topline metrics can be challenging to move and may require much
more significant changes, having a series of leading indicators is helpful, or
metrics that serve as a good indicator that a topline metric is also moving.
Given this information, we can improve the intermediate metric reliably
while assuming the topline metric will also show positive change. Because
of the difficulty in moving certain topline metrics, certain teams will take
company intermediate metrics as their topline metric. For example, a team
may work on increasing sharing as sharing is linked to overall increased
monthly active users. While the team cannot show meaningful change in
user activity each quarter, they can increase sharing.
Additionally, only viewing topline metrics can hide failure situations
that intermediate metrics reveal. Lastly, intermediate metrics allow for
precise estimation of topline metric movement aiding in planning and
estimation work for the next development cycle.
2
www.cloudflarestatus.com/history
3
www.cloudflarestatus.com/incidents/1z125rykf9zd
442
Chapter 13 Performance
F unnel Logging
When considering performance metrics, we must also consider intermediate
stages in the application. Unlike the preceding stand-alone intermediate
metrics, the intermediate metrics here visualize the different stages of the
performance measurement and provide a funnel-like view of the problem,
allowing engineers to build a holistic picture of the performance bottleneck.
An example of this style of funnel logging is when evaluating end-
to-end application latency. Our topline metric is end-to-end latency
(assume you are on a performance team). However, assessing solely end-
to-end latency does not provide enough information to succinctly debug
problems because it does not help us uncover where in our application
the bottleneck is. Is it network latency? Data processing in the application?
We do not know. However, if we craft intermediate metrics logged at these
steps, we can understand precisely how much time each scenario takes.
For example, when measuring end-to-end response time for network
requests, we would start the event on the network request sent and then
measure when each of the following steps completes:
1. When the request was received
2. Cache availability status
3. Data processing
4. Image loading
5. Finally, view rendering
Apple provides the Signpost API, which allows for the measurement
of tasks using the identical subsystems and categories used for logging.
Xcode Instruments can display data recorded via the Signpost API to
the timeline view. Additionally, Signposts can be used as part of custom
instruments to represent the data.4
4
https://developer.apple.com/documentation/os/ossignposter
443
Chapter 13 Performance
Evaluating Percentiles
Once we implement logging and understand the topline metrics, we need
to understand the data we receive. For performance metrics, we need to
understand the typical value that users receive. We could evaluate the
average (mean), but this is susceptive to being skewed by outliers. Instead,
we can utilize the median in the form of a percentile.
First, we can focus on the P50 threshold, the value at which 50% of the
values exceed the threshold. As a concrete example, we have the following
latency values: 20, 37, 45, 62, 850, and 920. To compute P50, we remove the
bottom 50% of the data points and look at the first remaining point: 62 ms.
In addition to evaluating the median value to understand the average use
case, assessing the long tail of potential values is essential to understand
the worst-case scenario users see. To do so, we can start with the P90
latency, meaning that the latency is expected to be less than this value 90%
of the time. By removing the bottom 90% of numbers from our sample data
and looking at the first point, which remains, we get 920.
By aggregating data and evaluating the typical user experience, we can
better understand the experience with our application and track potential
regressions. Using percentiles for this has two advantages:
444
Chapter 13 Performance
Analyzing the P90 and P99 values allows for a better understanding of
why certain users have relatively worse experiences and can reveal areas
of opportunity. It is easy to overlook the P99 latency as “well, only 1% of
users will ever see this.” However, there is a chance that within this data
lies a trend. Perhaps this 1% of users are all the same users with a common
attribute. In this situation, you can significantly improve these users’
experience with the application.
445
Chapter 13 Performance
446
Chapter 13 Performance
447
Chapter 13 Performance
Application Growth
Depressingly, the performance of an application is expected to decrease
over time. Every new release cycle adds more and more features that
can reduce overall performance. Often the performance difference is
unnoticed and is further hidden by hardware improvements that make it
possible to hide the regression.
This is the “death by 1000 cuts” principle. Engineers will say they are
only adding a minor feature, which will have no performance impact.
When 50 other engineers, all working on the same application, make
the same claim repeatedly for years, suddenly, the performance has
significantly regressed.
Having a holistic approach to performance monitoring, essential
logging in place, and guardrail metrics reviewed before launching new
features can avoid some performance regressions. However, performance
regressions still creep in, and many companies choose to spin up
dedicated performance teams to keep application performance in check.
448
Chapter 13 Performance
DoorDash has an excellent blog post discussing the process their team
went through to address application start. First, they utilized Emerge Tools’
Performance Analysis tool to profile their application for bottlenecks.
Emerge Tools provides additional granularity and a richer overall feature
set than Xcode Instruments.
The DoorDash team understood and utilized the available tooling
to profile their application. While profiling the application, they found
that it spent excessive time checking if a type conforms to a protocol
(Swift protocol conformance), illustrated in Figure 13-1. After further
investigation, they discovered that using String(describing:) to identify
services came with a runtime performance penalty for checking if the type
conforms to various protocols.
449
Chapter 13 Performance
Once the team identified the root cause, they could eliminate the string
requirement and switch to identifying types using ObjectIdentifier (a pointer
to the type), yielding 11% faster app startup times.6 Figure 13-2 displays the
detailed stack trace of the type checking to occur under the covers.
5
https://doordash.engineering/2023/01/31/
how-we-reduced-our-ios-app-launch-time-by-60/
6
https://doordash.engineering/2023/01/31/
how-we-reduced-our-ios-app-launch-time-by-60/
7
https://doordash.engineering/2023/01/31/
how-we-reduced-our-ios-app-launch-time-by-60/
450
Chapter 13 Performance
8
https://doordash.engineering/2023/01/31/
how-we-reduced-our-ios-app-launch-time-by-60/
451
Chapter 13 Performance
452
Chapter 13 Performance
Performance Metrics
Application Size
While application size does not affect in-app performance directly, it
influences users looking to download the application. It places hard limits
on users with low disk space and limited networking resources. If users run
out of space on their devices, they will have to choose what applications to
keep, and the largest applications are typically deleted first. No one wants
their application deleted.
453
Chapter 13 Performance
9
www.itu.int/en/ITU-D/Statistics/Pages/facts/default.aspx
10
www.itu.int/en/ITU-D/Statistics/Pages/facts/default.aspx
454
Chapter 13 Performance
11
https://medium.com/airbnb-engineering/
building-airbnbs-internationalization-platform-45cf0104b63c
12
www.youtube.com/watch?v=UKqPqtvZtck
13
https://developer.apple.com/videos/play/wwdc2019/423/
455
Chapter 13 Performance
456
Chapter 13 Performance
457
Chapter 13 Performance
458
Chapter 13 Performance
14
https://developer.apple.com/videos/play/wwdc2021/10258/
459
Chapter 13 Performance
1. Time Profiler.
2. System Trace.
460
Chapter 13 Performance
Once the application ships, we can use our monitoring and alerting to
detect hangs. We can use out-of-the-box Apple solutions on device hang
detection, MetricKit, and Xcode Organizer.
1. MetricKit is the out-of-the-box solution provided by
Apple. MetricKit supports collecting nonaggregated
hang rate metrics and diagnostic reports from
individual users on your beta or public release app.15
2. XCode Organizer provides an aggregate hang rate
metric for users of the publicly released application.
15
https://developer.apple.com/videos/play/wwdc2022/10082
461
Chapter 13 Performance
462
Chapter 13 Performance
Battery Drain
Users prioritize applications that do not cause them to recharge
constantly. In fact, users can even see which applications are draining their
phone’s battery the most. If your application uses a large percentage of
battery power compared to other applications, users may stop using your
application or uninstall the application entirely.
To further debug battery drain problems, we can review CPU usage
closely related to battery drain. Clicking on the debug navigator allows you
to view the energy gauge, which tracks the CPU usage of the application
throughout a debugging session. Figure 13-6 shows the CPU report panel
for our Photo Stream application. High CPU overhead is over 20%. Spikes
are common during network requests and data processing; otherwise,
CPU usage should be close to zero. If we see continuous CPU usage, this
can represent a problem.
463
Chapter 13 Performance
A
pplication Crashes
Application crashes happen; however, as engineers, we must mitigate
their frequency as much as possible. By adding logging and monitoring
for application crashes to include stack traces, engineers can monitor the
overall crash rate for the application and fix crashes based on the highest
priority. Tracking crash rates in the beta testing phase before releasing
a new feature to public users is critical. Overall crash rates should also
be tracked via MetricKit or another third-party tool to monitor overall
performance.
To debug a problem using a crash report, we can utilize the following
best practices:
1. Retrieve the crash report.
16
https://developer.apple.com/documentation/xcode/
identifying-the-cause-of-common-crashes
17
https://developer.apple.com/documentation/xcode/
adding-identifiable-symbol-names-to-a-crash-report
464
Chapter 13 Performance
What Is Symbolication?
When releasing an application, removing compilation steps that
include debugging information from the final binary format is standard.
Eliminating these steps obfuscates and minifies your source code by
removing unnecessary characters, making the binary smaller and more
difficult to reverse engineer. However, when an error is received, stack
traces with obfuscated information make it impossible to track down the
source of the crash.
Symbolication is the process of converting unreadable function names
or hexadecimal memory addresses (in the case of iOS) into human-
readable method names, file names, and line numbers. The crash report
must be symbolicated to determine the exact reason behind the crash
reliably.
Even with a symbolicated crash report, debugging based on a crash
report can be complex and may require extra steps to replicate. If you
cannot reproduce it, sometimes, relying on other engineers with different
devices or requesting QA to replicate the bug can be helpful, especially in
providing a symbolicated crash report. Otherwise, add additional logging
to the build and review the logs to understand better what could cause
the crash.
Network-Related Metrics
Networking is essential to any modern mobile application where network
access is required to pull updated information and sync across devices.
Given the ever presence of mobile devices, the chances of end users
making requests in low network areas are high. Your application’s ability
to handle these situations provides a robust application solution for users.
One factor to consider is all possible edge cases, such as what happens
if a user goes through a tunnel. By thinking about these situations,
holistic solutions can be developed with input from product managers.
465
Chapter 13 Performance
1. Network latency
2. Network load
3. Network errors
Latency
To properly understand network latency, we must track the API latency,
intermediate points throughout the application, and the end-to-end
response time for the overall network request time to the view rendering.
Once we have proper logging to evaluate the latency at different portions
of our application, we can group the network traffic into percentiles and
evaluate the latency for other users. Armed with this information, we can
assess the application’s performance for most users (fiftieth percentile –
P50) and outliers (ten percent – P90). Using P50 latency, we can set an
average bar for application latency and hold a standard for the application.
Using P90 latency, we can evaluate outliers to understand the worst-case
scenario. The outlier data can reveal interesting trends and potential areas
for improvement. For example, if we ascertain that almost all of our P90
end-to-end latency comes from cold starts on 3 or 4G, we know there is a
specific scenario we can improve on.
In addition to evaluating the network latency, measuring the
cache miss rate and retrieval time is also helpful in understanding the
performance. There can also exist nuanced trade-offs between waiting
longer for network content and quickly displaying content from the
cache. For example, in our Photo Stream application, the fresh photos
from the network have a more accurate ranking score (ML-backed story
466
Chapter 13 Performance
ranking). They are also more relevant (updated and recent) than photos
stored on the on-device cache. However, waiting longer to load the first
story negatively correlates with session length and users returning to the
application. Here, it is unclear how long we should wait on a network
request before showing content from the cache. We must balance the
positive effects of fresher, more relevant network content with the negative
impact of network latency. To arrive at the optimal solution, we can run
an experiment with different timeout settings – the time we wait for the
network request before showing content from the cache.
Load
In addition to network latency, we must consider network load, which
refers to the number of network transactions or calls over a specific period.
Application performance degrades under high network load. Even if this
does not cause a noticeable UI lag for the user, it can cause a noticeable
battery drain causing users to uninstall your application. By profiling the
application in Xcode, we can evaluate the network load. For a more holistic
understanding of the network load on users’ devices, additional logging
can be added to track the number of in-flight network requests.
Errors
Networking errors also contribute to an overall poor application
experience and can degrade performance by requiring retries and
contributing to crashes.
Engagement Metrics
Engagement metrics often track company-critical topline metrics and are
typically geared toward tracking revenue generation and product growth
either into different user segments or geographics. Some examples include
467
Chapter 13 Performance
3. Geographic location
4. Session length
468
Chapter 13 Performance
// Repositories/PhotoRepository.swift
photoRepository.getAll().receive(on: DispatchQueue.main)
// Repositories/PhotoRepository.swift
} receiveValue: { [weak self] photos in
guard let sSelf = self else {
return
}
sleep(4)
sSelf.allPhotos = photos
.compactMap{ $0 as? PhotoModel.Photo }
}.store(in: &cancellables)
469
Chapter 13 Performance
Now, when we run our trace, we can see a clear hang displayed in
Figure 13-8 as the red block. With on-device hang detection, we also get a
notification on the device regarding the hang.
So how can we fix this? We can move our Dispatch to the main thread
logic to the view-model, freeing the main thread and removing the hang.
// Scenes/PhotoStream/PhotoStreamViewModel.swift
photoModel.allPhotosPublished.receive(on:
DispatchQueue.main).map
Figure 13-9 documents the performance trace using the App Launch
Instruments tool. Now when viewing the trace, it is clear the hang is gone.
Even with the sleep statement blocking the completion of the data load, we
have made the UI usable. Notice that on the device, you can switch tabs to
the settings tab, whereas before, the UI was completely stuck.
470
Chapter 13 Performance
Figure 13-9. Trace showing how to move the dispatch sync and
remove the hang
Summary
Performance is a critical component of application development.
Degraded performance can cause increased battery drain, laggy user
experience, crashes, and bloated binaries, all of which lead to lower user
engagement. By integrating performance testing, establishing critical
metrics for monitoring and alerting, and ensuring performance tooling
is well understood, we can mitigate performance regressions and hold a
high bar for overall application health. Debugging performance problems
is a massive space deserving of its own book. Here, we have covered a
framework for prioritizing, understanding, and addressing performance
problems by tracking key metrics and understanding the principles of
performance investigations.
471
Chapter 13 Performance
F urther Learning
1. How Apple optimized app size and runtime
performance
a. https://developer.apple.com/videos/play/
wwdc2022/110363
2. Ultimate application performance survival guide
a. https://developer.apple.com/videos/play/
wwdc2021/10181/
472
Chapter 13 Performance
a. https://developer.apple.com/documentation/xcode/
improving-your-app-s-performance
a. https://developer.apple.com/videos/play/
wwdc2022/10082
473
CHAPTER 14
Experimentation
As software engineers, we must diligently test any changes we make
throughout all steps of the SDLC. Testing encompasses performance
testing, correctness testing (automated tests), and production
experimentation to validate the user’s response to the changes. In scientific
terminology, production testing is referred to as hypothesis testing.
Typically we have a hypothesis that users will respond favorably to a new
feature, change, or performance improvement. However, we must test and
validate our idea before concluding the change is successful. To include
hypothesis testing in the SDLC, we need a basic understanding of statistics
and software experimentation infrastructure to support such testing.
This chapter assumes your application has the necessary software
infrastructure for testing and instead focuses on the statistical knowledge
needed to correctly set up experiments and analyze the results. While
statistics may seem simple, it is not always intuitive, which can lead to
drawing inaccurate conclusions.
1
A scientific statistics-based test done in a controlled environment as to manage
external factors.
476
Chapter 14 Experimentation
477
Chapter 14 Experimentation
478
Chapter 14 Experimentation
U
niverses for Experimentation
In our example setup, a universe consists of all eligible users (statistically
speaking, a universe is our population suitable for testing). We can have
many universes that will exist orthogonally, containing the same users
implying that universes are not mutually exclusive. A user can be in
multiple experiments across universes at the same time.
Inside a universe, we allocate users to different experiments. We will
utilize a hashing algorithm to assign users to a specific segment, which is
then allocated inside an experiment.
Example segment hashing algorithm where we produce 1000 segments:
479
Chapter 14 Experimentation
In this way, the same user can only be in one experiment in a universe,
allowing experiments inside a universe to be mutually exclusive. Once
an experiment is deallocated, those users return to the pool of users
eligible for other experiments. Figure 14-1 outlines the universe setup and
potential experimentation allocation strategy.
While Figure 14-1 outlines the allocation strategy, Figure 14-2 shows
the potential UI an engineer using the experiment engine may see when
creating a new experiment inside a universe. The figure is from the Darkly
platform, a third-party experimentation framework. Note how we also
select metrics we want to monitor; the importance of this will come into
play later on in this chapter.
480
Chapter 14 Experimentation
Experiment Tooling
Inside an experiment, we want to control user allocation to the test and
control group (or multiple test groups). For this type of setup, we need
to consider the capabilities of our analysis platform carefully. While
it is statistically possible to compare differently sized test and control
groups, not all platforms support this type of analysis. Figure 14-3 outlines
the allocation of three test groups all receiving 5% traffic in the Darkly
platform.
2
https://launchdarkly.com/features/experimentation/
481
Chapter 14 Experimentation
Figure 14-3. Example allocating users to the test and control groups
referred to as variations on the Darkly platform3
3
https://launchdarkly.com/features/experimentation/
482
Chapter 14 Experimentation
4
https://launchdarkly.com/features/experimentation/
483
Chapter 14 Experimentation
484
Chapter 14 Experimentation
5
https://arxiv.org/pdf/2012.08591.pdf
485
Chapter 14 Experimentation
an example of this on the Darkly platform. This chapter will dive into
the statistical calculations underlying this dashboard and why they are
important.
To calculate this in real-time or near real-time, we will need to create
data pipelines that take the raw metrics such as error rates or, in the Darkly
example, pagination. Once the data is collected, we need to be able to run
statistical analyses (the topic of the next section) on the data. Lastly, we
need to display this in an easy-to-consume and understand format.
6
https://launchdarkly.com/features/experimentation/
486
Chapter 14 Experimentation
487
Chapter 14 Experimentation
488
Chapter 14 Experimentation
Now that your team has decided on what idea to try, you design how
to implement the feature. As part of the feature design, you also design an
experiment to test the feature. For this, you will use an A/B test where you
have one set of users who are excluded from seeing the Discover tab bar
icon (the control – a) and a group who will see the new experience (the test
group – b).
After one month (the time necessary for metric stability), you will
use your company’s experimentation framework to observe key metrics
related to your initial hypothesis (around overall traffic to the Discover
tab and any performance regressions). Then you will consider how the
outcome of these data points relates to the original hypothesis and if we
can disprove the null hypothesis. Lastly, you will share the findings with
the broader team.
489
Chapter 14 Experimentation
In the rest of this chapter, we will dive into the hypothesis definition,
experimental design, and results analysis steps for our example. We will
skip over data collection because data collection is typically automated
via data pipelines, and metrics are pre-computed for software engineers
with data engineers or separate teams of software engineers handling the
pipeline creation and maintenance themselves. Creating a data ingestion
system and subsequent metrics computation pipelines is a complex topic
worthy of its own book.
490
Chapter 14 Experimentation
491
Chapter 14 Experimentation
effect we hoped for, we can accept the alternative hypothesis and launch
the feature or fail to reject the null hypothesis and go back to synthesize a
new hypothesis.
7
Muff, S., Nilsen, E. B., O’Hara, R. B., & Nater, C. R. (2022). Rewriting results
sections in the language of evidence. Trends in ecology & evolution, 37(3), 203–210.
https://doi.org/10.1016/j.tree.2021.10.009
8
Zar, Jerrold H. (1999). Biostatistical Analysis (4th ed.). Upper Saddle River, N.J.:
Prentice Hall.
9
Dekking, Frederik Michel; Kraaikamp, Cornelis; Lopuhaä, Hendrik Paul; Meester,
Ludolf Erwin (2005). “A Modern Introduction to Probability and Statistics.”
10
Illowsky, Barbara; Dean, Susan L. (1945-). Introductory statistics, OpenStax
College. Houston, Texas.
492
Chapter 14 Experimentation
situation, we may include a counter metric for overall visits to the main
feed. If we negatively impact this, it may hurt our chances of launching.
Additionally, we should monitor application and server performance to
understand any additional performance cost from the Discover tab change
(a guardrail metric).
493
Chapter 14 Experimentation
# Set seed for the random number generator, so we get the same
random numbers each time
np.random.seed(20210710)
Now we can view our distributions with our “small” and “large” sample
sizes in Figures 14-8 and 14-9. In Figure 14-8, the distribution of the values
is larger; thus, the 95% confidence interval is larger, and a wider range of
values would be deemed acceptable.
494
Chapter 14 Experimentation
495
Chapter 14 Experimentation
Note The sample mean is the most probable value for the true
mean. The 95% confidence interval denotes that we expect the
sample mean to fall within this range 95% of the time if we repeat
the experiment many times with different users.
496
Chapter 14 Experimentation
Results Analysis
Let us jump forward in time and say we have let our experiment on the
Discover tab run for one month, which, given our number of users, is
long enough to gather significant results. Understanding this time frame
is important for adequately evaluating results and is the result of many
factors, including experimentation power, which we will calculate later in
this section. To properly analyze experiment results, we must
Types of Errors
Understanding the potential for erroneous results and how to address
them is a big part of results analysis. We refer to these errors as Type I and
Type II errors. Type I and II errors are essential to understand because
our experiments are on a sample of users and not the entire population,
which introduces a degree of randomness in our metrics that can sway our
results, making it difficult or impossible to draw conclusions from them
(commonly referred to as noise). By understanding these error types, we
can better understand how to diagnose, interpret, and avoid them.
A Type I error represents the false-positive rate, the likelihood that
we will reject the null hypothesis when it is true. Regarding our Discover
tab entry point experiment, a Type I error represents concluding that our
feature has an impact on visitation and launch even though it does not
have an actual effect on visitation.
A Type II error represents the false-negative rate, the likelihood
that we will fail to reject the null hypothesis when it is false. Regarding
our experiment, a Type II error means that we will conclude that our
entry point has no impact on Discover surface visitation and fails to
497
Chapter 14 Experimentation
launch even though it does impact visitation. Figure 14-10 illustrates the
aforementioned in tabular format and includes the probability notation
that these errors map to.
498
Chapter 14 Experimentation
The Type II error, known as the beta (β), is set by the significance
level of the experiment. The Type II error rate is not set before starting
the experiment and instead depends on the significance level, size of the
delta, sample size, and data variance and is illustrated in Figure 14-12. The
inverse of beta is statistical power (shown in Figure 14-14).
Statistical power represents the probability of detecting an effect when
there is an actual effect. Power helps us to understand if we can reasonably
detect the effect we expect from the experiment. Statistical power is the
area under the alternative hypothesis distribution and outside the null
hypothesis’s confidence interval.
499
Chapter 14 Experimentation
(our new entry point does not affect visitation). If the p-value is less than
5%, there is little chance we would observe our new entry point affecting
visitation by chance. We can then confidently report that we have a
statistically significant result.
500
Chapter 14 Experimentation
P k ; n, p P X k p k 1 p k
nk
We can easily model this in Python utilizing the SciPy kit where
num_converted is k successes we see in our experiment (e.g., conversions
or visitations), the total is the n independent trials, and the probability
base conversion rate, bcr, is the base conversion rate of our control
(the expected rate).
11
www.itl.nist.gov/div898/handbook/eda/section3/eda366i.htm
501
Chapter 14 Experimentation
502
Chapter 14 Experimentation
return scs.binomtest(
num_converted-1,
total,
bcr,
'two-sided').pvalue
# $ python3 example3.py
print(p_val(70, 100, .5))
# 7.85013964559367e-05
503
Chapter 14 Experimentation
504
Chapter 14 Experimentation
12
https://web.stanford.edu/~kcobb/hrp259/lecture11.ppt
505
Chapter 14 Experimentation
# min_sample_size.py
import scipy.stats as scs
from plots import *
import math
def min_sample_size(
base_rate,
mde,
power,
sig_level
):
# standard normal distribution to determine z-values
standard_norm = scs.norm(0, 1)
min_sample_size = (2 * pooled_prob *
(1 - pooled_prob) * (z_beta + z_alpha)**2
/ mde**2)
return math.ceil(min_sample_size)
Now we can reliably detect our minimum required sample size before
running an experiment!
506
Chapter 14 Experimentation
Ronald L. Wasserstein & Nicole A. Lazar (2016). The ASA Statement on p-Values:
13
Context, Process, and Purpose, The American Statistician, 70:2, 129–133, doi:
10.1080/00031305.2016.1154108.
507
Chapter 14 Experimentation
508
Chapter 14 Experimentation
d
pb
pa
H 0 : d = 0
H 1 : d ≠ 0
To simulate data for our test, we will utilize pre-generated data with a
sample size of 2000 (1000 in each test and control group) with a theorized
rate of improvement of .02 between the test and control groups. To ensure
results match, the sample data set is included in the source code as
sample_data.csv.
import pandas as pd
ab_data = pd.read_csv('sample_data.csv')
# skip formatting code...
"""
converted total rate
group
A 94 985 0.095431
B 125 1015 0.122167
"""
Now we can inspect the raw visitation data and rates. Between the
test and control, the rate of change is about 0.03, which is close to the lift
we initially theorized of 0.02. Now, this provides an understanding of the
509
Chapter 14 Experimentation
overall rate of change and that this is a decent improvement between the
test and control groups. However, we have not assessed the statistical
magnitude of the change, and thus, we don’t have enough evidence to say
the change was truly effective.
First, let us format our sample data so we can reason about the total
members in our trial, the total that saw the discover tab (labeled as
converted users in code), and the rate for these groups.
# example3.py
# Conversion Data
a_group = ab_data[ab_data['group'] == 'A']
b_group = ab_data[ab_data['group'] == 'B']
a_converted = a_group['visited'].sum()
b_converted = b_group['visited'].sum()
a_total = len(a_group)
b_total = len(b_group)
510
Chapter 14 Experimentation
# example3.py
# Raw distribution
fig, ax = plt.subplots(figsize=(12,6))
xA = np.linspace(
a_converted - 49,
a_converted + 50,
100,
)
yA = scs.binom(a_total, p_a).pmf(xA)
ax.bar(xA, yA, alpha=0.5, color='red')
xB = np.linspace(
b_converted - 49,
b_converted + 50,
100,
)
yB = scs.binom(b_total, p_b).pmf(xB)
ax.bar(xB, yB, alpha=0.5, color='blue')
plt.xlabel('visited')
plt.ylabel('probability')
# display plot
plt.show()
511
Chapter 14 Experimentation
We can see that the blue test group had more visitations than the red
control group. The peak of the test group is also lower than the control
group, meaning the number of samples is different. Our sample means
are different and so do our sample size and standard deviations. To more
accurately compare our test and control groups, we must calculate the
probability of success for both the test and control groups.
To do so, we need to standardize the data for the differences in
population size and then compare the probability of success. First, we can
normalize our individual test and control distributions. For this, we need
to calculate the standard error for each group. By evaluating the standard
error, we can understand the variation in our sample data and see how
closely our sample values cluster around the mean.
To do so, we must define the mean and variance (standard deviation).
We can do so as follows where P is the success (visitation of the Discovery
tab) probability:
EX p
512
Chapter 14 Experimentation
Var X p 1 p
p 1 p
s p 1 p
x
n n
And utilizing the central limit theorem, we can define our distribution
as normal as follows:
p ~ Normal p, p 1 p
n
In Python, we can then model our standard error and create a new plot
of our distributions in Figure 14-16.
import numpy as np
import matplotlib.pyplot as plt
import scipy.stats as scs
Kwak, S. G., & Kim, J. H. (2017). Central limit theorem: the cornerstone of
14
513
Chapter 14 Experimentation
# example3.py
# standard error of the mean for both groups
se_a = np.sqrt(p_a * (1-p_a)) / np.sqrt(a_total)
se_b = np.sqrt(p_b * (1-p_b)) / np.sqrt(b_total)
"""
Standard error control: 0.009558794818157494
Standard error test: 0.010199971022850756
"""
514
Chapter 14 Experimentation
The distance between the red and blue dashed lines (denoting the
mean conversion rate) equals d . We had previously defined d as
d
pb
pa
515
Chapter 14 Experimentation
p pa pb
pool
na nb
1 1
SE pool p pool 1 p pool
na nb
Note When the null hypothesis suggests the proportions in the test
and control groups are equal, we use the pooled proportion estimate
( p ) to estimate the standard error.
Cote, Linda R.; Gordon, Rupa; Randell, Chrislyn E.; Schmitt, Judy; and Marvin,
15
516
Chapter 14 Experimentation
Once we have our pooled standard error, we can re-plot our data in a
normalized fashion and make an apples-to-apples comparison between
our test and control groups. To better visualize this relationship, we can
use some advanced plotting in Python to display the p-value, power, alpha,
and beta. Figure 14-17 illustrates this calculation, which implements our
previous calculations.
517
Chapter 14 Experimentation
Now reviewing our results, we can see that our statistical power is low.
While we see a change in the conversion rate of .03 and p-value of .006
(less than our alpha of .025), the low statistical power points to a potential
Type II error. If we launch our change globally after our experiment,
we may not realize our gains. One way we can improve the power is by
increasing the sample size. Before launching, we may call this out as a risk
and rerun the experiment with a larger sample size to validate our results.
Utilizing our min_sample_size calculator from earlier in this chapter,
we can re-plot our data with a sample size large enough to generate 80%
power, as shown in Figure 14-18.
518
Chapter 14 Experimentation
p_a,
d_hat,
b_converted,
show_power=True
)
519
Chapter 14 Experimentation
Common Pitfalls
Now that we have gone over hypothesis testing and the scientific
method applied for software engineers, let us discuss some common
experimentation pitfalls to avoid.
Experiment Pollution
Experiment pollution can occur when two experiments test a similar
change in a nonmutually exclusive way. Say one experiment adds an
additional tab entry point for the discover page while another change adds
520
Chapter 14 Experimentation
521
Chapter 14 Experimentation
P-Hacking
P-hacking is when engineers search through different filters and time
ranges (subpopulation selection) to identify a specific pattern that matches
their success criteria. P-hacking is common when there is business
pressure on an experiment to produce specific results, and the experiment
turns out to be neutral. A common p-hacking strategy is to modify the
population selection criteria, such as particular dates or device type filters
that cause the results to read as statistically significant even though the
overall aggregate is neutral. By doing this, the 95% confidence intervals are
not representative of the actual population (i.e., not guaranteed to contain
the truth 95% of the time).
Additionally, p-hacking results make it challenging to replicate your
experiment, given the initial assumptions. Engineering leaders should
review all experiments to ensure engineers are not p-hacking results to
produce the desired outcome.
522
Chapter 14 Experimentation
Dilution
Even if we do a proper power analysis and determine our MDE, we can
still run afoul. For example, if we expose 100% of users in our test group
on application startup, but we actually have only 40% of users who see
the treatment reducing our sample size by more than half. Especially if
we do not notice that this occurred, we may not realize we have seriously
changed our ability to detect changes.
523
Chapter 14 Experimentation
524
Chapter 14 Experimentation
Pre-AA Bias
Pre-AA bias occurs when one or both of the segments utilized for testing
have a previous bias affecting the current experimental results. Most
experimentation platforms will randomize users for experiment selection;
however, for some treatments that require careful measurement and can
have confounding factors, such as improving session loss, pre-AA bias
can still exist and negatively impact results. For other experiments, such
as latency measurement, AA bias may not matter. Work with your team to
understand the specific nuances of experimentation. In some situations,
it is necessary to start multiple simultaneous experiments or specific AA
variants to assess the effects of AA bias.
Additionally, some experimentation platforms can take away some
of the effects of pre-AA bias by calculating a seven-day average of the
required metrics before the experiment’s start date. This allows for a
normalization of the starting level when calculating different test statistics
later during the results analysis phase.
525
Chapter 14 Experimentation
Summary
Experimentation via A/B tests is a critical tool for software engineers
that allows us to validate and measure the effects of our changes. As
engineering leaders, we must think critically about how we design our
experiments and how we analyze the results to ensure we are reaching the
correct conclusions and shipping the most effective changes. To analyze
experiment results, you will likely rely on an experiment platform, not
custom Python code. However, understanding why we experiment the
way we do and what statistics underlie the methodology is important
when reviewing experiments for correctness and understanding potential
edge cases.
526
Chapter 14 Experimentation
Key Takeaways
1. Ensure the experimentation process follows the
scientific method. By doing this, you are applying a
standard battle-tested decision-making process.
Further Learning
1. Statistical Power Analysis for the Behavioral
Sciences (2nd Edition)
527
Chapter 14 Experimentation
528
CHAPTER 15
Application Release
and Maintenance
In our journey through the SDLC, we have worked through planning and
developing testable features via experimentation and automated tests.
Now it is time to release the completed feature or project. After release, we
need to ensure our feature is maintained correctly. While launching and
maintaining a small feature is relatively simple, releasing a large project is
more complex. Properly releasing and maintaining a large project at scale
takes time, coordination, and a mature build system and requires careful
planning to ensure proper monitoring for release. Figure 15-1 outlines
the SDLC’s maintenance and deployment (release) sections that we will
discuss in this chapter.
530
Chapter 15 Application Release and Maintenance
1
In this chapter, we use merge to generally mean combining changes from a
feature branch into the main (master) branch. Depending on version control
utilized, the specific terms may vary.
531
Chapter 15 Application Release and Maintenance
532
Chapter 15 Application Release and Maintenance
Much like the SDLC, the overall CI/CD pipeline can be broken down
into smaller steps that unblock the successful release process starting at
the individual pull request level and advance to a full production release.
At each level, we want the ability to have rapid high-signal continuous
feedback about the effects of developers’ code changes while efficiently
using the company’s compute resources (servers are not free after all). A
high signal-to-noise ratio is critical so that engineers do not waste time
tracking down failures unrelated to their changes. For instance, if every
time an engineer submits a pull request, several of the automated tests
run fail consistently, the engineer will not take those tests seriously. Their
failures will be bypassed, and changes will land anyway. If this behavior
continues, the tests will not provide a useful signal and will instead be
classified as noisy. Without proper testing, potential production problems
are more likely to occur. Rapid and frequent, highly accurate (high-signal)
feedback is crucial so engineers can fix bugs in their code quickly and not
attempt to bypass or ignore tests to save time (potentially due to business
pressures or unreliable tests).
To meet the time pressures and reliability constraints to deliver high-
fidelity results while maintaining a low server compute load, we must run
different builds and test suites at different times based on
1. Coverage: Do not build and test it unless the change
may actually have broken it.
2. Speed: Faster builds and tests consume fewer
resources
3. Flakiness: Flaky builds and tests are unreliable
and disruptive. A flaky test is a common term for
a test that passes and fails irrespective of code
changes, making it an unreliable estimate of code
correctness.
4. Correlation: If builds always succeed or fail together,
build only one on every diff.
533
Chapter 15 Application Release and Maintenance
534
Chapter 15 Application Release and Maintenance
helps alleviate the concern of running too many tests on each iteration
of the pull request (utilizing valuable compute resources and causing
additional lag time for engineers) and helps to prevent failures due
to merge conflicts. Lastly, once the commit is merged into the master
branch, we can run additional tests to ensure that the master branch is
stable. Checking the master branch adds an additional layer of protection
against merge conflicts and changes that, while independently passing all
automated tests, interact badly together, causing failures.
535
Chapter 15 Application Release and Maintenance
536
Chapter 15 Application Release and Maintenance
3
https://engineering.fb.com/2017/08/31/web/rapid-release-at-
massive-scale/
537
Chapter 15 Application Release and Maintenance
4
https://developers.soundcloud.com/blog/building-a-healthy-on-
call-culture
5
https://medium.com/airbnb-engineering/building-mixed-language-ios-
project-with-buck-8a903b0e3e56
6
https://buck.build/
538
Chapter 15 Application Release and Maintenance
Advanced build system tools like Buck are made for monorepos
(a monorepo is a version-controlled code repository that holds many
projects). Advanced build systems are a common way to manage large iOS
applications because they reduce tooling maintenance and standardize
the development process across related areas of code. A well-designed
monorepo consists of small, reusable modules, and Buck leverages this
to intelligently analyze the changes made and build only what is needed.
Buck specifically provides caching and takes advantage of multicore CPUs
so that multiple modules can be built simultaneously, including unit tests.
Uber leveraged Buck’s distributed caching feature as part of their build
system optimizations so that when a target is built, it is not recompiled
until the code in that target (or one of the targets it depends on) changes.
That means you can set up a repository where the tooling will intelligently
determine what needs to be rebuilt and retested while caching everything
else. Engineers at Uber saved time using the artifacts compiled remotely
on CI servers locally for their builds.7
Continuous Delivery
We can establish a release cadence now that we have a stable build system
and optimized build time. For our internal release cadence, we can utilize
our build system to deliver updated builds based on the stable master
branch consistently. For this process, we can integrate with a tool like
Fastlane8 that helps automate the code signing and release process, saving
engineers from manually making these changes. With these capabilities,
we can deliver nightly builds to QA teams and enable company-wide
dogfooding on stable master branch builds. These builds can be delivered
7
www.uber.com/blog/ios-monorepo/
8
https://fastlane.tools/
539
Chapter 15 Application Release and Maintenance
9
https://developer.apple.com/testflight/
10
W. Harrison, “Eating Your Own Dog Food” in IEEE Software, vol. 23, no. 03,
pp. 5–7, 2006, doi: 10.1109/MS.2006.72
540
Chapter 15 Application Release and Maintenance
At this point, we can build, test, and dogfood our feature on internal
builds, but we have yet to release (or deploy) it to end users. The release
stage has its own engineering challenges. At a high level in the release
stage, we must ensure the following:
541
Chapter 15 Application Release and Maintenance
542
Chapter 15 Application Release and Maintenance
1. Launch blockers
a. A new feature you want to launch has a bug that will delay
the launch unless fixed. You can, however, disable the feature
with a feature flag.
2. Company-critical blockers
Release Oncall
To handle the release process, teams often establish a rotation of engineers
responsible for releasing the application, known as the release oncall
(sometimes referred to as “captain”). This helps distribute the burden of
release responsibility among team members, reducing the pressure and
cognitive load associated with managing application release and avoiding
a single point of failure associated with having the same engineer always
release the application.
During their week of service, the release engineer should focus on
resolving any launch-blocking issues and ensuring that all automated tests
for the build are passing. They should also monitor key metrics during the
weekly push. Depending on the application’s scope and scale, multiple
release oncall engineers may be required. To reduce the preparation time
and help onboard team members faster to the release oncall, we should do
the following:
543
Chapter 15 Application Release and Maintenance
544
Chapter 15 Application Release and Maintenance
545
Chapter 15 Application Release and Maintenance
546
Chapter 15 Application Release and Maintenance
1. Performance constraints
2. Privacy standards
3. Security standards
5. UI standards
547
Chapter 15 Application Release and Maintenance
548
Chapter 15 Application Release and Maintenance
3. Privacy specialists.
549
Chapter 15 Application Release and Maintenance
5. Sales teams.
When aligning for a major launch, we must work closely with all
business partners to meet all business requirements. Additionally, there
may be required external communications and other policy changes
that will influence the launch timeline. For example, if the marketing
team needs to release a series of global communication posts before the
engineering launch, then from an engineering side, we must make sure not
to launch until the marketing is also aligned.
550
Chapter 15 Application Release and Maintenance
notice or perceive any gaps, you must raise the issue and push for a fix.
For example, suppose you know that a privacy review must be completed
before launching, and you have not seen or done a privacy review to
avoid missing a critical step. In that case, you must raise the issue with the
appropriate business stakeholders to push for a resolution.
In addition to business concerns that could block a launch for metric-
driven teams, it is critical to position the launch at the right time for
metric readings. Some teams will have specific metric reading weeks to
ensure stability. Or for ads, holiday seasons are typically avoided due to
massive ad pricing shifts. It is not possible to be aware of every nuanced
requirement, such as ad pricing shifts. Still, it is critical to assess the
situation and discuss it with relevant stakeholders with experience to
ensure the launch is timed correctly.
551
Chapter 15 Application Release and Maintenance
Release
Once a project can pass the limited release stage without any regressions,
it is finally ready for release. At this point, all changes should be included
in the production application and disabled via a feature flag (part of the
experimentation framework). By releasing behind a feature flag, we can
control the rollout speed and closely monitor the effects on users.
During the release process, the team must identify a good time for the
release. For example, releasing on a Friday is a bad idea since no one is
available to monitor the release on Saturday and Sunday. Once a sensible
release date is picked (perhaps Tuesday since Tuesday aligns well with
our hypothetical release cycle listed earlier), we must also create a plan to
manage any problems with the release. We need a clear point of contact
in case something goes wrong and a clear set of metrics to monitor to
ascertain if something is going wrong.
After release, we should start a backtest. A backtest is when we hold
out our feature from a small number of users to monitor the continued
effects using our experimentation framework. We should see consistency
between the pre-test experiment readings and the backtest readings. If we
do not see consistent readings, this may indicate an underlying issue with
the production feature.
552
Chapter 15 Application Release and Maintenance
Logging
Effective alerting and monitoring require proper logging. It is essential to
add logging at crucial stages during feature development to collect the
necessary data to monitor the project. Engineers should ensure that the
logging added allows them to prove that their feature is working correctly
at a granular level. For instance, if the project requires delivering ads
with a gap of two photos between them, logging is crucial to show the ad
and photo’s ordering and delivery on both the server and client. To avoid
missing logging, it is recommended to add a specific section for logging
in to the project planning document and the task tracker. This way, the
team can review the logging and provide input if they feel more or less
is required and progress is trackable. Additionally, creating a specific
logging-related section explicitly mentions any changes in data collection,
which can trigger privacy reviews.
Once we have logging in place, the data we collect is referred to
as metrics. Metrics can represent anything from performance-related
concerns we discussed earlier, including crash rates, user engagement,
entry rates, and anything else critical for your application or project.
Metrics recorded from logging are the base values utilized to correlate
diverse factors, understand historical trends, and measure changes in your
consumption, performance, or error rates.
553
Chapter 15 Application Release and Maintenance
Monitoring
Metrics refer to the data in your system, while monitoring involves
collecting, aggregating, and analyzing these values to gain a better
understanding of your components’ characteristics and behavior. Creating
a dashboard to visualize these metrics using company tooling is also
important. In the monitoring phase, it is crucial to aggregate the raw metric
data and present it in a way that allows other engineers to comprehend the
significant parts of the system’s behavior.
Converting raw metric data into usable aggregations can be a time-
consuming process. To effectively present the data and visualize alert
thresholds, assessing specific conditions and identifying deviations from
expectations are essential. For instance, for daily visitation metrics, we
may need to create trend-based graphs or use slow drift detection to track
gradual changes over time. On the other hand, for crash rates or timeouts,
percentile checks (like the performance section) can help us understand
the typical user experience (P50) and outliers (P90, P99) so we can set
appropriate static thresholds. For example, if we notice a spike in average
application latency that exceeds what we believe to be the P99 case, it
could indicate an apparent issue with the system.
To enable effective data aggregation, the monitoring system should
retain the data and track the metrics over time. This allows us to leverage
the data trends to understand the expected and desired behavior and
define actionable alerts based on divergence from historical trends.
Alerting
Alerting is a crucial component of a monitoring system that takes action
based on changes in metric values. Alerts are made up of two properties:
554
Chapter 15 Application Release and Maintenance
11
www.pagerduty.com/, a commonly used alerting service.
555
Chapter 15 Application Release and Maintenance
Maintenance
Once the release is successful, the team will move on to new work, and
the release-related extra attention will diminish, leaving the regular teams
oncall to handle issues. And because we have instituted proper alerting
and monitoring, we are prepared to detect any unexpected problems.
A typical team will institute oncall rotations to receive alerts for any
production issues.
If the team is working on projects that heavily rely on metrics, they
may need to frequently report the backtest numbers to ensure that they
are on track to meet their goals. For instance, if the team has committed
to increasing the revenue by 2% within a year, then the revenue number
in the backtest should steadily approach a 2% gain as more projects
are launched. Any fluctuations or reductions in revenue could pose a
significant challenge for the team.
Summary
This chapter completes our tour of the SDLC. We have now discussed
planning, building, testing, and releasing critical projects. With proper
planning, we set ourselves up for smooth project execution, and by
considering early release constraints, we can position the team for a
smooth release process. First, we must have the proper CI/CD system set
up to manage large-scale project maintenance and release. Next, we must
lead the critical components of the project release.
556
Chapter 15 Application Release and Maintenance
557
Chapter 15 Application Release and Maintenance
Key Takeaways
1. Plan early and revise often. During the software
building phase, it is critical to continuously ensure
that the project is on track (or trending toward)
meeting the launch criteria.
558
Chapter 15 Application Release and Maintenance
Further Learning
1. Trunk-based development
b. https://trunkbaseddevelopment.com/
a. www.uber.com/blog/how-we-halved-go-monorepo-ci-
build-time/
559
PART IV
Leading At Scale
CHAPTER 16
Leading Multiple
Teams
We have made it through quite a journey thus far. Now, we must discuss
how to put together the different portions of this book (iOS fundamentals,
software architecture, SDLC skills, and project leadership best practices)
in the context of expectations and soft skills necessary for success. At
engineering levels past senior, it takes more than just engineering chops to
prioritize and execute projects especially over long periods where building
up junior engineers and cultivating good relationships across the broader
organization are essential.
At the point in your career that you start to lead multiple teams and
own broader product direction, most tech companies split into two
parallel tracks: management and senior IC (individual contributor). The
senior IC levels typically map to staff and principal. At staff and principal
levels, engineers need not only the technical skills acquired in the first two
parts of this book but also the leadership and soft skills to successfully lead
large teams.
One method to model the technical skills required to reach these
super-senior levels is the t-shaped model. Figure 16-1 outlines the t-shaped
model where the depth of experience comes from the first two parts
heavy on iOS fundamentals and architecture. Meanwhile, the breadth of
knowledge comes from the chapters centered around leading projects,
experimentation best practices, CI/CD best practices (dev-ops), and
564
Chapter 16 Leading Multiple Teams
launch – a focus of this chapter. You will truly partner with management
to achieve team goals and set technical direction. Additionally, we will
discuss mentorship. Success is not defined merely by executing one project
but by being able to mentor, develop, and grow a team for years to achieve
multiple successful projects. By merging our soft skills with our technical
abilities, we can achieve this level of success and grow with our team.
Engineer Archetypes
A senior engineer must have sufficient scope, breadth, and depth of
knowledge. All three increase at each increasing level of seniority, and
ignoring any one of them will not help. Here, we will define the following
engineer archetypes:
1. Fixer
2. Tech lead
3. Architect
4. Executive assistant
Fixer
Fixers can dig deep into complex problems and drive solutions where
others cannot. An iOS-focused example of a fixer could be an engineer
who understands the entire application architecture and can drive
performance improvements by fixing esoteric bugs or improving overall
565
Chapter 16 Leading Multiple Teams
application architecture patterns. This fixer can operate across the whole
application, both product and underlying infrastructure layers. A fixer can
also identify a problem and, instead of fixing it on their own, lead a team to
address the issue.
Tech Lead
The tech lead is the most common engineering archetype and partners
closely with managers to drive progress and lead projects to success. An
iOS-focused example of a tech lead could be a senior engineer leading
the onboarding flow team. This engineer is the primary point of contact
for all new features and oversees technical architecture, scoping, and goal
setting for the onboarding flow of the application. As tech leads become
increasingly senior, their scope increases, and they will lead multiple
teams with subordinate tech leads.
Architect
The architect is typically a role reserved for higher-level engineers working
at or beyond an organization level. They are primarily responsible for a
critical area’s direction, quality, and approach. Architects rely on in-depth
knowledge of technical constraints, user needs, and organization-level
leadership to execute successfully. An iOS-focused example of an architect
would be the individual who designed the overall architecture flow of the
application. For instance, the architect of a messaging application will
need to handle client-side caching, message receiving, message sending,
notification handling, and overall metadata storage (among other things).
They work with other back-end architects to mesh the front end into the
holistic software system.
566
Chapter 16 Leading Multiple Teams
Executive Assistant
The executive assistant role is rare and typically involves close partnership
with organization leaders to provide engineering perspective to leadership
and commonly works on helping to staff critical but unknown projects
across an organization. In general, they provide additional leadership
bandwidth in large-scale organizations. An example of an executive
assistant could be an engineer who works closely with senior management
to identify unstaffed but critical projects and then reaches out across the
organization to find senior engineers with the bandwidth to take on the
projects. The executive assistant will then help track project progress and
provide technical guidance and mentorship if required.
Given the variety of roles and overall flexibility, there is no one true
definition. For instance, the Staff Engineer blog by Will Larson describes
these senior engineer archetypes slightly differently.1 Moreover, the exact
job role will also differ depending on the level of seniority and company
expectations. Regardless, each archetype must mentor and lead other
engineers. Thus, irrespective of the exact senior archetype, soft skills
(interpersonal skills that characterize a person’s relationships with
others) are critical for long-term success and growing your career beyond
individual contributors.
Throughout this chapter, we will refer to the senior engineer as the
tech lead; however, most information applies to any senior leadership
archetype. Some portions may apply less if you are an executive assistant
or fixer since these tracks rely on slightly different skill set (with the fixer
leaning more technical and the executive assistant leaning toward more
breadth and managerial skills). Regardless, for most senior engineers, it is
critical to be able to lead and deliver successful projects.
1
https://staffeng.com/guides/staff-archetypes/
567
Chapter 16 Leading Multiple Teams
Breadth
Senior engineers need to have expertise in both breadth and depth, which
they can apply to both existing systems and the creation of new ones.
They should have a thorough understanding of the system, enabling them
to break it down into smaller parts and delegate tasks to subteams or
individual engineers.
Depth
Depth in software development refers to the capacity to design or
implement a highly complex segment of the system, which only a few
individuals can accomplish. It indicates that you thoroughly comprehend
your strengths and can effectively apply them to real-world issues.
568
Chapter 16 Leading Multiple Teams
Scope
In some rare cases, you may be a specialist solving exceedingly complex
technical problems in a very specialized narrow domain, but this is not
the norm. In most situations, the expectation is for senior engineers to
have sufficient scope for their current level. Here, we classify our levels as
follows:
569
Chapter 16 Leading Multiple Teams
570
Chapter 16 Leading Multiple Teams
571
Chapter 16 Leading Multiple Teams
572
Chapter 16 Leading Multiple Teams
573
Chapter 16 Leading Multiple Teams
improvements will not add value on top of the machine learning team’s
work. This requires setting achievable goals or working with the machine
learning team to understand the impact of low-value regions and how the
models can evaluate new user targeting there.
For a more product and infrastructure-based team, this may involve
ensuring that all portions of the project mesh together correctly. For
example, suppose you are leading a migration to a new infrastructure stack
and are responsible for both the product UI integration and product infra
team to create the middle-layer APIs for the product teams. In that case,
you will need to ensure that the middle layer meets the specifications of
the product team and that all items are included. If the intermediate layer
does not correctly account for syncing metadata across devices and this is
not discovered until later testing, the entire rollout may be delayed.
In both situations, the tech lead is also responsible for laying out
the overall path for launch. This will consist of a series of experiments
on the leading launch candidate to ensure the team is confident in a
successful launch. As discussed in past chapters, this may involve heavy
usage of the experimentation framework to monitor critical metrics.
Here, communication is also vital. The tech lead must establish what test
versions are required and the goal for each test. The tech lead must also
communicate with all involved parties to understand the launch timeline
and ensure critical cross-functional pieces are in place.
574
Chapter 16 Leading Multiple Teams
6% /.6% = 10
575
Chapter 16 Leading Multiple Teams
576
Chapter 16 Leading Multiple Teams
577
Chapter 16 Leading Multiple Teams
The tech lead’s role here is more nuanced than necessarily leading
and code-based project. They must think through and ensure the key
experiments are run to establish leading performance levers and that the
launch candidate can be reasoned about and adequately formed.
578
Chapter 16 Leading Multiple Teams
on delegating the tasks that the people around you can do and concentrate
on the things that most require your attention – for example, designing and
presenting the overall project architecture, designing and implementing the
most complex portion of the project, or serving as the de facto point of contact
to represent your team in meetings, allowing the rest of the development team
to write software meeting-free (if your team is very meeting heavy).
579
Chapter 16 Leading Multiple Teams
580
Chapter 16 Leading Multiple Teams
1. Leadership style
Leadership Styles
Hersey and Blanchard coined the situational leadership model to
characterize leadership styles based on the degree of task and relationship
behaviors that leaders provide to their followers. They classified all
leadership styles into four behavior styles denoted as S1 to S4. However,
the names of these styles vary depending on the version of the model used.
Here, we will define them as follows:
2
Hersey, P. and Blanchard, K. H. (1977). Management of Organizational Behavior
3rd Edition – Utilizing Human Resources. New Jersey/Prentice Hall.
581
Chapter 16 Leading Multiple Teams
Across all four styles, the leader is responsible for the following:
582
Chapter 16 Leading Multiple Teams
583
Chapter 16 Leading Multiple Teams
Development Levels
Development levels are also task specific even within software engineering
and determined by the commitment to solving the task and competency
to do so:
584
Chapter 16 Leading Multiple Teams
585
Chapter 16 Leading Multiple Teams
586
Chapter 16 Leading Multiple Teams
Common Pitfalls
Oversupervision: Micromanaging
Micromanaging is a leadership style in which a manager or supervisor
closely oversees and controls every aspect of their team members’ work.
This can include monitoring their every move, giving excessive feedback,
and taking on tasks that should be the responsibility of team members.
Micromanaging can be detrimental to team members’ morale, motivation,
and autonomy, leading to reduced productivity and burnout.
To avoid micromanaging, leaders can take several steps:
1. Set clear expectations: Communicate each project
or task’s goals, expectations, and deadlines. This will
help team members understand what is expected of
them and reduce the need for constant monitoring.
3
Blanchard, Kenneth H. (2003). The one minute manager. [New York]: Morrow, an
imprint of HarperCollins Publishers.
587
Chapter 16 Leading Multiple Teams
Undersupervision
Undersupervision occurs when a manager or supervisor fails to provide
adequate support and guidance to their team members. This can lead to
confusion, frustration, and a lack of direction, which can ultimately impact
team members’ performance and job satisfaction.
To avoid under supervision, leaders can take several steps:
588
Chapter 16 Leading Multiple Teams
589
Chapter 16 Leading Multiple Teams
590
Chapter 16 Leading Multiple Teams
Practical Example
As we have done throughout the book, we will provide a practical example
of using situational leadership in practice. Here you are, a senior leader
and your team has just hired Jon.
Jon has just graduated college and is pleased to get a job. He is
highly motivated and feels he can quickly acquire the skills for the job
(representing D1, S1). As the tech lead, you provide Jon with direction on
completing simple development tasks and check in frequently.
After a while, you notice Jon is getting frustrated that it is taking him
longer to understand the build system and process for landing high-
quality code than he initially thought. After talking with him, he shared
that initially, the slow build times and his unfamiliarities with coding led
to many iterations with long lag times. However, now that he understands
the system a bit better, he is becoming more efficient with fewer cycles but
is becoming frustrated with the particular build cycle time and blames the
build system for slow feature development. Jon has reached D2 and is at
risk of becoming unmotivated by these headwinds.
To counter this, you switch to S2, explain why this is happening,
redirect, and re-teach him the importance of preventing bugs before
running time-consuming tests. You also provide some tips on how to
leverage Buck to compile a smaller, more focused changeset faster. Over
time you follow the employee closely and see his continued progress. He
is becoming more and more independent and completing tasks more
quickly. Now you become more responsive and supportive of their choices
and help to build confidence as Jon moves toward D3.
Fast-forward three years, and you see Jon no longer consults you for
feedback and makes impressive independent choices. You recognize his
expertise and leverage him for complex tasks that challenge him, including
giving Jon a level four project, whereas previously, Jon was completing
tasks at a level three. As Jon grows to take on more level four projects, he
may slide back to S2 or S3. This is fine and even expected as you provide
more guidance on how he can grow to take on additional complexity.
591
Chapter 16 Leading Multiple Teams
Notice how in our practical example the level of scope Jon takes
grows from three to four as he cycles forward and then back in situational
leadership levels. This mirrors the promotion path of engineers, and while
promotions are the job of managers, senior engineers’ mentorship and
guidance play a critical role in the promotion and growth process.
Trade-Offs
The situational leadership model does not consider working toward multiple
tasks and goals at one time and does not explicitly consider this when
reviewing levels. Additionally, the model does not consider how employees
express their lack of ability or motivation. Some are overconfident, and some
will hide their true intent out of fear of reprisal or losing their job.
To combat these problems, the practitioner of situational leadership is
required to be a capable leader. The leader must understand the different
leadership requirements and have competency across those tenets, which
can require additional self-learning and experience. In this book, we
have reviewed those competencies. Additionally, as a tech leader, you are
not alone. You should partner with your manager to better understand
individual needs, concerns, and pressing goals to prioritize the correct
level of leadership involvement.
1. Mentor people
592
Chapter 16 Leading Multiple Teams
As you become more senior in your career, you will work more
closely with your manager, almost as a partner, both striving to solve the
same problems, just from different angles, with you spending more time
on solving the technical challenges and your manager spending more
time on people, resourcing, and sometimes building cross-functional
relationships.
593
Chapter 16 Leading Multiple Teams
Building Relationships
In addition to mentorship, senior engineers must be experts in building
relationships. To develop good relationships with those around us in both
technical and nontechnical capacities, we help ensure that people will
want to work with us. In fact, they will enjoy working with us and want to
provide feedback because they believe in you and the projects you lead
and are thus bought-in to the success of the project.
There are multiple models to approach building relationships; three
that we will present here work well together for building good relationships
with coworkers regarding difficult technical work-related conversations,
broader social situations, and team building.
594
Chapter 16 Leading Multiple Teams
How to Be Nice
Being nice can be divided into three parts. The first is in technical
conversations and decision-making. In a technical context, we want to
be nice and make sure others feel their opinions are valid and heard.
However, we also want to be firm with what we think the right decision
is. By supporting others instead of dismissing ideas, you facilitate an
open environment where everyone’s feedback is valued, and they feel
comfortable sharing.
595
Chapter 16 Leading Multiple Teams
The second form that being nice takes is in everyday interactions and
meetings. This applies to first impressions and overall getting to know your
coworkers. Learn about your coworkers, not just their in-office work, but a
little about themselves outside of work. By forming personal connections,
you help build positive relationships with your coworkers, improving trust
and communication. Trust and communication are critical components of
a healthy team.
To build this connection, you can strive to understand what their
hobbies are, do they have children (if so, how old?), and when are their
birthdays. You should not pry into their lives, but these conversations
can come up organically through simple questions like “How was your
weekend?” When they mention something like, “Oh, this weekend I went
to my kid’s soccer game,” now you can ask, “How long have they been
playing soccer?” Or “How old are they?” Now you have had a very typical
conversation, and it is on you to remember the details so you can ask
about their kid’s soccer progress in the future. The idea is to have regular
everyday conversations but ask for pertinent details (active listening) and
then do whatever you must to remember relevant information. You can
take notes or add birthdays to a particular calendar.
The last part of being nice is providing clear, actionable feedback with
concrete examples. This is nice because it is the expectation of a good
leader; you are doing those around you a favor by being honest and up-
front with them. By providing concrete examples, you make the feedback
as actionable as possible.
596
Chapter 16 Leading Multiple Teams
4
Dreeke, R., & Stauth, C. (2017). The code of trust: an American counterintelligence
expert’s five rules to lead and succeed. First edition. New York, St. Martin’s Press.
5
Walsh, B. (December 1, 2014). How to Win Friends and Influence People.
Director, 68(4), 32.
597
Chapter 16 Leading Multiple Teams
598
Chapter 16 Leading Multiple Teams
decisions you make and factors into how you build relationships and
be nice since your friends are naturally more inclined to agree with you.
Building good relationships with your team and across the broader
business organization can make your work environment happier and more
productive for everyone.
Summary
In this chapter, we reviewed the different senior engineer archetypes,
which have a lot of overlap in responsibilities. That is, you must
consistently lead large-scale projects while supporting the growth and
development of other junior engineers. We can help these engineers by
applying the situational leadership model. At this point, you can synergize
your skills as a t-shaped developer across both breadth and depth and
leverage different communication patterns via being nice, influencing
others, and building trust to get things done in a complex operating
environment.
In the book’s next and final chapter, we will discuss a theoretical
example of putting together different projects at each level for a mythical
company to fully understand how projects are broken down and scoped
and the unique challenges and expectations faced at each level.
Key Takeaways
1. If you choose to stay as an IC as your career
progresses, you will reach a parallel track with
management as a staff or senior engineer. To find
success at this level of seniority, in addition to
technical skills, it is paramount that you
599
Chapter 16 Leading Multiple Teams
d. Mentor and help those around you grow, which helps you
also to scale up to lead larger teams
Further Learning
1. Robin Dreeke
600
CHAPTER 17
Practical Example
This chapter is a culmination of the different parts in this book. We will
outline the role of engineers at various levels to show their day-to-day
activities and how they utilize specific skills to accomplish their jobs.
We will see how very senior engineers (staff and principal) rely on
communication, software architecture, and breadth of knowledge, while
ground-team senior engineers rely more heavily on technical depth. In all
cases, a certain amount of technical depth is required to ensure projects
adhere to project goals, timelines, and budget constraints.
To illustrate these situations and interactions between engineers, we
will review a narrative sample of a top-down project MVP iteration. We
have included a sample project plan at the end of this chapter.
602
Chapter 17 Practical Example
iterate toward a broader shared goal faster and the bottom-up approach is
useful for innovation working toward clear goals with open-ended ways of
accomplishing them.
603
Chapter 17 Practical Example
In response to the recent privacy trend, Mango CEO Steve and the
rest of the C-suite are discussing a significant upgrade to the security
infrastructure, moving the Photo Stream application to utilize end-to-end
encryption (E2EE). This is a complicated endeavor because the Photo
Stream application consists of a Connected feed showing content from
connected users (users who have agreed to connect and share content
with each other), a direct message feature for directly sending and sharing
photos or videos (including support for group messaging), and a Discover
feed that shows unconnected content primarily from influencers. Both the
feed experiences show advertisements that are also the company’s primary
means of revenue generation.
To assist in scoping the changes necessary for E2EE, the company CTO,
who focuses more on business strategy for the entire family of applications,
suggests working with Principal Engineer Erica, the chief architect of
the Photo Stream product group, who has a deep and comprehensive
understanding of the application as a whole, including the current security
model and the client-server architecture.
Erica meets with the CTO, Photo Stream VP of product, VP of design,
VP of Photo Stream data and analytics, and her manager, Matt (a director
in the Photo Stream project group), to discuss the switch to end-to-end
encryption and align on the overall project direction. They agree to start
small with a minimum feature set where only the direct message feature is
E2EE and only for 1:1 chat messages.
Jumping into Erica’s Day…
Erica sits at her desk after meeting with her manager to discuss moving
the Photo Stream application to end-to-end encryption. She briefly checks
her email to catch up on outstanding high-priority items for her twenty-
percent time project1 (improving the organization’s experimentation
best practices). Next, Erica begins writing a five-year plan based on the
directional alignment reached with her manager and other key business
1
A common term for a part time project taken in addition to one’s main project.
604
Chapter 17 Practical Example
partners. Erica reflects on the main product surfaces for the Photo
Stream application, including the iOS, Android, and web applications.
Erica incorporates these with the necessary underlying infrastructure
components and forms a rough draft of a five-year plan to first migrate the
infrastructure to E2EE, support base product requirements, and iteratively
launch, starting with the minimally viable project.
Throughout her week, she meets one on one (1:1) with key
stakeholders across the mobile, client, infra, back-end infra, and web
teams to drive the overall architecture further and build a comprehensive
plan. Erica is coaching and driving all stakeholders to align on utilizing
the existing infrastructure where possible. Additionally, Erica supports
using the signal protocol for E2EE, drastically reducing the engineering
implementation overhead and allowing support for iOS, Android, and web
clients.2
After formulating a rough draft, Erica meets with her manager Matt to
discuss the engineer selection (see the chapter appendix for the complete
project plan).
“Hi Erica, this proposal looks great. We should also sequence the
rollout with the infrastructure teams more. We must ensure they have the
availability to meet these requirements.”
“Thanks,” Erica replied, continuing, “I also wanted to discuss staffing.
We need at least one senior representative from each area in more detail
and delegate the different portions.”
Mike nods and responds: “Yes, I’ve already talked to the wider
org managers. We will have Mike (Android product), Steph (mobile
infrastructure), Tom (back-end infra), Kelly (web front end), and Blaine
(iOS product). For Blaine, I’m working on developing him to be the team
TL at the principal level. This project is good for him to show his technical
breadth. I’d like you to help mentor him to ensure he understands the
increased responsibilities.”
2
https://signal.org/docs/
605
Chapter 17 Practical Example
“Sure, I think Blaine will also need additional help with the technical
breadth here. He has mostly worked in the optimization space and has less
knowledge of the thread-view stack,” Erica responds. She then heads back
to her desk to schedule a meeting for the five of them to discuss kicking
off the project and some technical details necessary to finalize the five-
year plan. Erica also includes her manager as optional for context. She
organizes the meeting for one week away, and after she has her regularly
scheduled one-on-one sessions with Mike, Steph, Tom, Kelly, and Blaine.
This way, she can gather input from the first and build alignment on the
proposal before a larger meeting.
Erica starts to lead the meeting with the chosen engineer leads across
the mobile, web, mobile infra, and back-end infra areas. “Hi, everyone,”
Erica begins, “I hope you have all had a chance to review the preliminary
engineering plan, milestones, and architecture diagram. Today I would like
to review the tentative milestones and overall system architecture diagram
for how we want to approach migrating to E2EE.”
Erica proceeds to the architecture diagram of the current system state.
“First, let’s look at the client-server contract. We will utilize the existing
infrastructure wherever possible and add support inside the messaging
services for E2EE traffic.”
606
Chapter 17 Practical Example
Erica continues, “Next, for our mobile clients, we will follow existing
conventions and utilize our cross-platform C libraries with the signal
protocol for E2EE. This will allow us to guarantee consistency across
mobile clients and will integrate well with our existing C wrapper for SQLite
database access. This has the added benefit of reducing the binary size and
working with our existing push-based approach for messaging content.”
Erica further defines the feature set and follows milestones for
the group.
607
Chapter 17 Practical Example
“Does anyone have any questions on the milestones?” Erica says as she
opens the floor for questions. Blaine asks, “Regarding the careful metric
evaluation – What overall metric collections should we monitor? Are there
any ones we need specifically for E2EE?”
608
Chapter 17 Practical Example
609
Chapter 17 Practical Example
1. Data Analytics
2. Privacy Foundation
3. Core Infrastructure
4. Design (UI/UX)
5. Marketing
6. QA
610
Chapter 17 Practical Example
“I’m glad you brought up the project plan,” Blaine replies. “I need to
know who to talk to on the client infrastructure team for API endpoints
specification.”
“You should use Steph as your main point of contact. She is leading the
overall client infrastructure migration across iOS and Android. For next
week’s meeting, please ensure you have finalized the API contract with her
for iOS,” Erica replies. “Reviewing your project plan, I’m curious how you
plan to monitor your changes in production?” Erica asks.
“Oh, I assumed we would use the existing logging framework with a
flag for E2EE traffic. However, we may need to add additional logging at the
product level to understand specific traffic to the E2EE surfaces,” Blaine
comments.
“That’s good, but I feel there is some hidden complexity in how we
will handle this change, and it will be good for everyone on the team to
understand how you will handle this. Especially for a large-scale migration,
we must ensure that we have solid intermediate metrics for the experience.
This will help us verify the user experience,” Erica states. Before ending the
meeting, she advises Blaine to work with the Data Analytics team to review
metrics collection for potential experiment sizing.
With the direction provided by Erica, Blaine started to break down
the iOS product level into subprojects for engineers on his team. During a
meeting with his manager, Blaine explains his plan. “So I’ve broken down
the project into three sections. I will work directly with Samantha for the
thread-view and provide her with well-scoped tasks. I think the notification
handling portion will be a bit more complex and a good senior engineer–
scoped project we can give to Dale. Lastly, I’ve broken down the UI-related
changes into loosely scoped tasks for Evan and Kelly; they should have
enough coding-heavy tasks for the next quarter.”
“This looks great,” Blaine’s manager replies, “I think the large UI
tasks will be perfect for Evan and Kelly. One concern I have is regarding
Anthony’s work for the next half. He will need a large-scoped, more
ambiguous project.”
611
Chapter 17 Practical Example
“Yes, I’ve been thinking about this too,” Blaine says, “I don’t see enough
scope here for him, but I know group messaging is very important for the next
milestone. I want Anthony to focus on the group’s theme work. I know it isn’t
encryption related, but it is relevant product knowledge for the next milestone,
and having Anthony build that context is super valuable for the team.”
“Ok, I will talk to Anthony and see how he feels about the project. I
want to make sure he is good with the decision,” Blaine’s manager replies.
“Otherwise, I think this looks good. I like how you have divided up the
work for the team.”
Meanwhile, based on input from different team POCs, Erica creates
the overall timeline, including more detailed milestones for the V0 portion,
and reviews the work with her PM. With the finalized project plan, Erica is
ready to brief the VP-level leads she reports to and the CTO on the overall
strategy, success criteria, and milestones.
V0 Timeline:
612
Chapter 17 Practical Example
613
Chapter 17 Practical Example
5. We can see across the levels that the TLs are very
busy planning and scoping work for the team. Erica
and Blaine play vital roles in unblocking the teams
they are working with. While Erica purely focuses
on the project at hand as a team tech lead, Blaine
also works closely with his manager to ensure that
all engineers on his team have sufficient scope and
growth opportunities.
614
Chapter 17 Practical Example
for the teams and takes a complex portion of the project concerning
rewriting the thread-view for the direct messaging component, and aligns
with his team internally on connecting the new UI components with the
underlying APIs.
At the next status meeting, Blaine provides his update: “Progress is
on schedule; for iOS, we are ramping up end-to-end testing and ensuring
that the features are tested on real data. We are also starting a task board to
triage P0 or launch blocking bugs. So far, we haven’t gotten any, but we still
have to onboard more features for testing.”
“That is good to hear,” Erica replies, “for my update, I’m continuing
to work with the Data Analytics and privacy teams to gain alignment for
launch. We still need a list of company-critical metrics, and the privacy
review is in the final stages with legal. Lastly, we need to push the launch
date back one day to align with the marketing team’s timeline. They want
to run a few campaigns first and have a scheduled interview with a news
outlet in the EU. If there are no other updates, I will let you all go. Thanks,
everyone.”
After the meeting, Blaine returns to his desk and sees a message from
his PM. Hey, did you see the recent manual testing results? It looks like QA
is surfacing as a potential launch blocker. Reviewing the task board, Blaine
responds, “Oh, interesting, there is a significant delay for users with many
messages and contacts. This could definitely pose an issue to launch. I will
have the team start to investigate the root cause.”
After replying to his PM, Blaine starts a group chat with Erica and the
client infrastructure POC to inform them of the results.
Given the circumstances, Blaine raises this as a risk at the next weekly
sync with Erica. “Further investigation shows that bootstrapping the
underlying mailbox with E2EE is slow. From a product level, we have
ascertained that the regression is not happening at our level. We believe
this is infrastructure related; however, they are too busy to look into the
internal sync protocol for the inbox loading.”
615
Chapter 17 Practical Example
“I see,” Erica replies, “this is concerning since it may affect the rollout.
After this meeting, I will follow up so we can get some more eyes on the
problem.” Immediately after, Erica finds her manager and Sally at their
desk area: “Hi Sally, something concerning came up today. We may have
a sizable regression at the client infra level. I think we need to get some
engineers to look into this more. I’m curious if you can help us to review
the problem with Blaine and find appropriate staffing.”
“Yes, I can help with this. Let me connect with Blaine and then circle
back with you later today.”
Later that day, Sally comes back over to discuss.
“So this is definitely a big problem. I think we can have Garrick look
into this. I spoke with him and his manager; he has the bandwidth and is
interested in this type of work. He also has experience across infrastructure
and product-level features and is a senior engineer with a proven track
record of delivering high-value projects. I specifically mentioned that
we will need to handle the improvements to the sync protocol and add
additional logging so that the team can better monitor the regression in
production.”
“Awesome, glad this was easily staffable. In the meantime, I will
continue working with the performance team to understand how
they typically handle features that can regress performance. We must
understand their framework for trading off startup regression for other
metric wins.”
Erica and her manager Matt, who is helping to drive the discussion,
discovered that, unfortunately, the benefits of E2EE do not neatly fit into
the increased engagement that the performance team typically uses as
their trade-off measurement. Because of this, Erica does not feel she can
resolve this on her own and raises these concerns to leadership so they can
discuss how to measure this type of trade-off for E2EE, which is not seen as
an engagement win, but a necessary product change. Meanwhile, Garrick
continues to look into mitigating the problem.
616
Chapter 17 Practical Example
617
Chapter 17 Practical Example
618
Chapter 17 Practical Example
issues; they can direct them to Erica. Additionally, Erica monitors the
experiment setup and uses the biweekly meeting with TLs to review launch
metrics and track regressions.
In the following weekly meeting after the launch, Erica reviews the
metrics with the team. “The intermediate metrics look good – I don’t see
any red flags either. I am concerned with the overall level of traffic. Fewer
people are switching threads to utilize E2EE than expected. Is anyone on
the Data Analytics team here? Will we be able to gather enough signal from
this test?”
“Yes, I’m here; we have concerns as well. Given the low traffic, we
do not have a large enough sample size to understand performance
regressions across all devices. We can attempt to increase the experiment
size; however, this will not fully mitigate the issue.”
“Ok, let’s boost traffic for now, and we can sync up offline on improving
this for future iterations,” Erica replies. After the meeting, Erica approaches
Sally at their desk area.
“Hi Sally, do you have a minute to talk?” As Sally nods, Erica continues,
“The traffic for the latest E2EE experiment is very low, and I do not think
we will have adequate data across device types and geographic regions.
Especially for geographic regions, we see most adoption coming from US/
EU countries, with almost no adoption from LATAM. We will need to boost
traffic more uniformly before cutting over to E2EE fully; otherwise, we will
not have confidence in performance.”
“This makes sense. Perhaps we can leverage the same dual write
strategy we used before?” Sally says.
“Precisely my thoughts,” Erica replies, “I think we will need to scope in
the engineering effort for this as a hard dependency for cutover.”
“This sounds great, but we still need to work on how to staff this project
and further scoping on the timeline. I hope this does not delay the launch,”
Erica’s manager Matt chimes in. “Sally, can you work with the other senior
managers in the org to find an engineer with bandwidth and help set them
up for this project?”
619
Chapter 17 Practical Example
620
Chapter 17 Practical Example
Regardless of her archetype, these three behaviors are critical for long-
term success.
Tech Lead
We saw Blaine as a team TL delegate the different features and
components to team members while finding additional scope with help
from Sally for a senior engineer on the team (classified as a fixer). Blaine
also plans for future work by giving Anthony (a senior engineer on his
team) the themes project. Blaine knows Anthony’s experience on this
project will tie in to their future work.
Executive Assistant
As an executive assistant archetype, we saw Sally assisting in investigating
and staffing the project. Sally works closely with the management/
leadership team on understanding progress and “hotspots” areas of scope
that come up throughout the migration that are understaffed.
621
Chapter 17 Practical Example
Fixer
The last major engineering archetype is a fixer. Garrick represented this
role as he could work deeply in the stack without necessarily owning a
team. While in this case Garrick acted alone, a fixer could also drive a small
team on a new product or cross-cutting infrastructure feature.
Overall Notes
We also saw the team collaboratively owned the problem space. Erica
has included spots in the project plan for others to contribute, including
design for creating UI mockups, POCs to add links to their specific design
docs, and a spot for the cryptography expert to add their portion (see the
project plan in the appendix). Everyone contributed to driving the solution
in planning and had specific scope to own during execution.
While the ownership was collaborative, there were still some disputes.
Realistically no project is without conflict. In our example, the central conflict
to resolve was around the startup regressions. Navigating the conversation
of potentially regressing another team’s critical metrics is a challenging
conversation to have. Luckily in our example, the situation was easily
resolved by pushing a fix; however, this could have escalated and needed
more senior leadership to broker a contract. Senior leadership is required
in these circumstances because both parties need to share a common goal,
and that is more difficult further down the chain where parties will care more
about individual goals such as shipping the launch or avoiding a regression.
Another source of conflict here could have been over the logging
changes. Luckily in our example, the framework was flexible, and the client
infra team could easily control the logs via access permissions. However,
this could have become a much larger project if the access permission
framework did not exist or the client infra team was unaware of it. This
could have involved a lengthy back-and-forth with the privacy team and
potentially delayed the launch.
622
Chapter 17 Practical Example
Summary
Throughout our practical example, we see some senior engineer
archetypes: tech lead (Blaine), architect (Erica), fixer (Garrick), and
executive assistant (Sally). While roles sometimes overlap, we see how
everyone, especially at higher levels, needs a deep understanding of
technical concepts and people skills to resolve thorny conflicts and
provide actionable mentorship to others. Some, such as Garrick, require
more deep technical knowledge, while Sally leans into her people and
organizational skills more but still must rely on her technical skills to guide
projects. We can categorize these as their superpowers. Each has one, and
each is unique. For Erica, it is her overall technical depth and breadth of
knowledge in the stack. For Garrick, it is his deep technical knowledge and
ability to fix problems others cannot. When you reach staff and principal
levels, you will also need to find your superpower.
We also see how Erica fluidly moves through the SDLC steps, staying
ahead of the team to ensure that the rollout and future planning items go
well. This helps and provides subordinate leaders time to make their own
plans. It also requires enough technical expertise to move quickly and stay
ahead of subordinate leaders without working double the hours.
Irrespective of your archetype, mastering the art of integrating soft
skills with engineering core competencies is pivotal in propelling both
yourself and your career toward spearheading larger and more impactful
projects like Erica. To develop these essential skills, you can effectively
utilize all four parts of this book. First, enhance your technical expertise,
and then broaden your knowledge base and refine your soft skills.
623
APPENDIX
Completed
Five-Year Plan
Migrate to E2EE
Migrate the Photo Stream application to E2EE
Team: Photo Stream Application
Status: Draft
Last Updated: Thursday, May 21, 2023
Problem Alignment
Due to broader industry trends and the continued push toward privacy-
first experiences, we would like to move as much of the Photo Stream
application as possible to utilize E2EE.
High-Level Approach
To facilitate the move to E2EE, we will start with a small feature set and
move toward broader sections of the application. At each stage, we will
include testing. Once we have reached parity with non-E2EE features, we
will perform a hard cutover where all new experiences will use E2EE by
default. For nonmessaging surfaces, we will transition the feed tab to show
only fully connected users’ content via E2EE shared keys and keep the
Discover tab non-encrypted to support ease of open content sharing.
Goals
Milestone Success Criteria Rollout Plan
MVP – MVP Enable E2EE for 1:1 direct Usage is optional, so the
feature set messages, including experience will be gated behind
photos with minimal to no a feature flag backed by an
performance regressions experiment and slowly rolled out
to users after internal testing
V1 – expanded Enable E2EE features for Usage is optional, so the
feature set groups and content sharing. experience will be gated behind
Minimal to no performance a feature flag backed by an
regressions are required experiment and slowly rolled out
to users after internal testing
V2 – advanced Enable a tree-based system Usage is optional, so the
E2EE features of asymmetric encryption experience will be gated behind
keys to create an encrypted a feature flag backed by an
connected feed experience. experiment and slowly rolled out
Minimal to no performance to users after internal testing
regressions are required
V3 – cutover and Entirely switch all Usage is no longer optional.
stabilization encryption-enabled modes Changes are gated behind a
to the default experience feature flag and experiment. Once
for users. Minimal to no the rollout is complete, 100% of
performance regressions are the public on eligible application
required versions will use E2EE features
626
appendix Completed Five-Year Plan
Solution Alignment
Overall we will utilize the existing infrastructure for the migration, namely:
627
appendix Completed Five-Year Plan
For encryption, we will utilize the signal protocol, which provides the
following:
1. Anonymity preservation
1
N. Unger et al., “SoK: Secure Messaging,” 2015 IEEE Symposium on Security and
Privacy, San Jose, CA, USA, 2015, pp. 232–249, doi: 10.1109/SP.2015.22.
628
appendix Completed Five-Year Plan
629
appendix Completed Five-Year Plan
630
appendix Completed Five-Year Plan
Future considerations:
Key Flows
TODO: Insert mock to designs once ready from UI/UX design team.
Key Logic
Client
631
appendix Completed Five-Year Plan
Server
1. Support storage read and write for E2EE data. Once a
message is delivered, it must be deleted from the server.
2. Use the existing message queue system to deliver
messages to all connected devices.
3. Use existing server infrastructure to store associated
metadata.
L aunch Plan
Date Milestone Success Criteria Rollout Plan
Column
2023-11-14 MVP – MVP Enable E2EE for 1:1 Usage is optional, so the
feature set direct messages, experience will be gated
including photos behind a feature flag
with minimal to backed by an experiment
no performance and slowly rolled out to
regressions users after internal testing
(continued)
632
appendix Completed Five-Year Plan
633
appendix Completed Five-Year Plan
K
ey Milestones
Target Date Milestone Description Exit Criteria
634
appendix Completed Five-Year Plan
Operational Checklist
Team Requirements for Launch Complete
Y/N
635
appendix Completed Five-Year Plan
Changelog
Date Description
Jan 15, 2023 Updated architecture diagram to include the fact that the logging
module will have to change
Open Questions
636
Index
A percentiles, 444, 445
techniques, 437
A/B tests, 526
tools, 446, 447
Access control lists (ACLs), 64
topline metrics, 439–442
Access permission framework, 622
user experience, 438
Actor model, 132, 152
Application release/maintenance
Agile development cycle, 381
large project, 529
Agile method, 380
SDLC, 530
AI-based integration testing tool, 425
Application-wide architecture
allPhoto’s property, 248
patterns, 179, 241
American Statistical Association
Archetypes, 623
(ASA), 507
chief architect, 620, 621
Apple ecosystem, 3, 25
executive assistant, 621
Application architecture patterns,
fixer, 622
176, 180, 274, 566
TL, 621
Application design patterns,
Architect (Erica), 623
274, 276, 321
Asynchronous function
Application performance
annotations, 131
application growth, 448
Asynchronous programming, 111
continuous testing/evaluation,
Atomic, Consistent, Isolated, and
452, 453
Durable (ACID), 76
data-driven approach, 439
Automated integration testing, 173
debugging problems, 448,
Automatic reference counting (ARC)
449, 451
bugs, 44
engineers, 437
definition, 36
funnel logging, 443
developers, 36
intermediate metrics, 442
memory deallocation, 37, 38
monitoring/alerting, 445
memory management, 37
Occam’s razor, 451, 452
638
INDEX
release infrastructure D
constraints, 545, 546
Data analytics, 611
engineering challenges, 541
Data collection method, 485
measure system success, 545
Data-driven approach, 439
release oncall, 543, 544
Data propagation techniques
SDLCs, 542
event loop, 334
successful launch, 540
incremental state update,
release process, 558
336, 337
requirements, 547–549
push/pull models, 332, 333
SDLC, 558
Swift runtime environment, 334
testing phases, 536, 537
topological sorting, 337, 338
test suites, 533
update/event propagation, 335
three one-month cycles, 531
Delegate pattern architecture, 185
trunk-based development, 536
Dependency injection (DI), 212
Contract testing, 410, 426–428
Dependency inversion, 164
Controller-based networking, 248
Design patterns
Core data framework
application-level, 179
definition, 70
architectural trade-offs, 238
disadvantages, 74, 75
builder, 192–201
easy-to-use set, 73, 74
building blocks, 180
managed objects, 71
coordinators, 217–230
NSManagedObjectContext, 71
delegate pattern, 184–188
NSPersistentCoordinator, 72, 73
facade, 188–191
NSPersistentStore, 72
factory, 201–208
SQLite, 76
mobile engineers, 181
storage formats, 70
observer, 230–238
Cost of concurrency
overarching themes, 182, 183
difficult to debug, 117
pseudocode, 180
shared state, 117
singleton, 208–212
threads costs, 115–117
software engineering
unblocking, 115
problems, 239
Creational design patterns, 182, 183
Dining philosophers
Cursor-based pagination
problem, 144
method, 91
639
INDEX
640
INDEX
Good architecture L
modular/testable, 160
Larger-scale application, 293, 438
dependency inversion,
Large-scale distributed testing
164, 165
framework, 172
interface segregation
Large-scale multinational
principle, 164
applications, 27
LSP, 162–164
Last-in, first-out (LIFO) data
open-closed principle, 162
structure, 29
single responsibility
Lead multiple team
principle, 161
product direction, 563
SOLID, 160
t-shaped model, 563, 564
testing, 159
Lemon parser, 80
well-architected
like method, 421
applications, 159
Liskov Substitution Principle (LSP),
Grand Central Dispatch (GCD), 113
162, 163
Lower-level mobile library, 58
H
Helper method, 258–260, 266 M
HitchhikersOracle, 184
MainTabBarViewController, 268
Hypothesis
Mango, 603, 604
testing, 475–477, 487
Manual testing, 410, 411, 429,
435, 436
I, J Map-reduce programming
model, 345
Immutable view-models, 297
Massive view controller, 263
Inline value buffer, 49
Memory management
Inlining, 45, 55
heap allocation, 34, 35
Integration testing, 409, 423
memory usage
buffer underflow/
K overflow, 32
Key-Value Observing (KVO), heap, 30, 31
130, 235 program memory layout, 28
641
INDEX
642
INDEX
examples O
combine bindings, 287
Object-based structural design
equatable protocol, 284–286
patterns, 183
PassthroughSubject, 290, 291
Objective-C, 3
PhotoModel, 288–290
Occam’s razor, 451, 452
render state function, 292
Operation
update coordinator, 292
queues, 129, 130, 148
view-model, 284
Optimistic data model
modularity, 295, 296
approach, 353
reactive programming, 295
testability, 296
trade-offs, 294 P, Q
view-model, 278, 279
Pact testing’ s client-driven
Modularity, 160
approach, 428
case studies
Parallelism, 111, 112
Airbnb’s designing,
Performance
productivity, 169, 170
metrics, 471
ComponentKit, 167, 168
regressions, 471
design patterns, 170
Performance metrics
sound architecture
application crashes, 464, 465
principles, 170
application
Uber redesign, scale,
responsiveness, 459–462
166, 167
application size, 454, 455
single framework/library, 166
battery drain, 463
Monad, 336, 342, 346
engagement metrics, 467
Monoid, 345
networking
Mutual exclusion, 110, 138
definition, 465
MVC-based iOS application, 70
errors, 467
latency, 466, 467
N load, 467
practical
Nested view-models, 296
example, 468–471
NSNotification framework, 247
startup time, 456, 457, 459
NSUserDefaults, 65, 66, 106
643
INDEX
644
INDEX
645
INDEX
646
INDEX
647
INDEX
648