Model-Defined Interfaces
A Pattern for Better, Safer, and More Explicit API
Most iOS architectural patterns rely on protocols to define what data and behaviors (i.e. interfaces) will be available to client code. This is a perfectly workable approach, but it adds quite a bit of overhead in terms of creating the protocol and multiple conforming types.
For example:
protocol AuthenticationProvider {
var isAuthenticated: Bool { get }
var authToken: String? { get }
var authError: Error? { get }
func logInWithCredentials(_ credentials: Credentials)
func logOut()
}
This protocol minimally needs to be adopted two times: once for the actual production functionality, and once by a mock type for testing:
class AuthenticationManager: AuthenticationProvider {
var isAuthenticated: Bool
var authToken: String?
var authError: Error?
func logInWithCredentials(_ credentials: Credentials) {
// Add _production_ implementation
}
func logOut() {
// Add _production_ implementation
}
}
class MockAuthenticationManager: AuthenticationProvider {
var isAuthenticated: Bool
var authToken: String?
var authError: Error?
func logInWithCredentials(_ credentials: Credentials) {
// Add _mock_ implementation
}
func logOut() {
// Add _mock_ implementation
}
}
So at a bare minimum, this approach requires the creation of three significantly redundant types in order to use.
Now let’s contrast with a model-defined interface approach. Instead of declaring a protocol that must be conformed to, we declare the same data and behaviors as a model struct. Instead of having protocol methods, the struct contains closures that can be invoked:
struct AuthenticationModel {
let isAuthenticated: Bool
let authToken: String?
let authError: Error?
let logInWithCredentials: (Credentials) -> Void
let logOut: () -> Void
}
Here there is no need to conform to a protocol or define redundant concrete types. Instead for a screen or class that depends on this model, the production code can pass in an instance with “real” closures and values, while test code can simply initialize the struct with mock values and closures like this:
let mockAuthenticationModel: AuthenticationModel =
.init(isAuthenticated: false,
authToken: nil,
authError: nil,
logInWithCredentials: { loginExpectation.fulfill() },
logOut: { XCTFail("Incorrectly called logOut") })
testSubject = .init(authmodel: mockAuthenticationModel)
So we can see that model-defined interfaces immediately reduce redundancy and the number of types which must be defined. However, this is just the beginning and a relatively minor benefit. The most important benefits of model-defined interfaces are compiler-enforced safety, reduction in test cases and defensive code, and self-documenting API.
Compiler-Enforced Safety
Looking at the AuthenticationModel
example above, you might notice some potential for misuse or undefined behavior. Specifically:
- What happens if you call
logOut()
when the user is already logged out? - What happens if you call
logInWithCredentials()
when the user is already logged in?
In fact, when it comes to authentication functionality, we don’t want to allow either of these possibilities to even happen at all!
And these sorts of cases are where model-defined interfaces really shine. Here is how we can adjust our model to more accurately represent the safe usage of this API:
enum AuthenticationModel {
case authenticated(Authenticated)
case notAuthenticated(NotAuthenticated)
struct Authenticated {
let logOut: () -> Void
}
struct NotAuthenticated {
let logInWithCredentials: (Credentials) -> Void
}
}
We have now made AuthenticationModel
an enum with two distinct states or cases: .authenticated
and .notAuthenticated
. Note that the logOut
closure only exists in the .authenticated
state, and the logInWithCredentials
closure only exists in the .notAuthenticated
state. Because of this definition, it is now impossible for a developer to mistakenly invoke a closure in the wrong state because the compiler itself won’t allow it!
This kind of contextual API surface is simply not possible to express using protocols!
Reduction in Test Cases and Defensive Code
Beyond the potentially unsafe behaviors we were able to clean up above by limiting their existence only to the correct state, there are more potential issues you may have noticed with our original protocol:
protocol AuthenticationProvider {
var isAuthenticated: Bool { get }
var authToken: String? { get }
var authError: Error? { get }
func logInWithCredentials(_ credentials: Credentials)
func logOut()
}
Specifically, we have a few properties that suggest implicit but not documented rules. For example:
- There is an optional
authToken
property, which should probably benil
whenisAuthenticated == false
and should probably never benil
whenisAuthenticated == true
. - There is an
authError
property, which should conversely never benil
whenisAuthenticated == true
, since authentication was successful and so shouldn't have resulted in an error!
These are in addition to the already noted implicit rules that
- If
isAuthenticated == true
thelogInWithCredentials()
method should be ignored - If
isAuthenticated == false
thelogOut()
method should be ignored.
In a typical protocol-based approach, these implicit rules would need to be enforced through test cases and defensive code. For example, defensive code would look something like this:
class AuthenticationManager: AuthenticationProvider {
var isAuthenticated: Bool
var authToken: String?
var authError: Error?
func logInWithCredentials(_ credentials: Credentials) {
guard !isAuthenticated else { return } // <-- Defensive code to early exit if called in wrong context
// Perform actual log in here
}
func logOut() {
guard isAuthenticated else { return } // <-- Defensive code to early exit if called in wrong context
// Perform actual log out here
}
}
and example test cases might look something like this:
// If we log in with valid credentials we should be authenticated and should have a token
func testAuthTokenIsNotNil() {
let authentication = AuthenticationManager()
authentication.logInWithCredentials(.validTestCredentials) // We pass in valid credentials
XCTAssertTrue(authentication.isAuthenticated) // We expect authentication to succeed
XCTAssertNotNil(authentication.authToken) // If authentication succeeded the token must not be nil
}
// If we attempt login with invalid credentials we should not be authenticated and should NOT have a token
func testAuthTokenIsNil() {
let authentication = AuthenticationManager()
authentication.logInWithCredentials(.invalidTestCredentials) // We pass in invalid credentials
XCTAssertFalse(authentication.isAuthenticated) // We expect authentication to fail
XCTAssertNil(authentication.authToken) // If authentication didn't succeed the token MUST be nil
}
// If we log in with valid credentials there should not be any error
func testErrorIsNil() {
let authentication = AuthenticationManager()
authentication.logInWithCredentials(.validTestCredentials)
XCTAssertTrue(authentication.isAuthenticated) // We expect authentication to succeed
XCTAssertNil(authentication.error) // If authentication succeeded the error property should be nil
}
It's worth pointing out that the defensive coding and test cases needed to validate these implicit rules would need to be implemented for every type that conforms to the protocol!
Let's finish converting this protocol-defined interface to a model-defined interface and see what happens:
enum AuthenticationModel {
case authenticated(Authenticated)
case notAuthenticated(NotAuthenticated)
struct Authenticated {
let authToken: String
let logOut: () -> Void
}
struct NotAuthenticated {
let error: Error?
let logInWithCredentials: (Credentials) -> Void
}
}
Note how we have added the error
and authToken
properties to only the appropriate context. And the isAuthenticated
property is itself replaced by the enum cases of .notAuthenticated
and .authenticated
. What does this achieve? Well since it is no longer possible to even attempt to access the authToken
or call logOut()
in a .notAuthenticated
state, we can remove all defensive coding associated with validating the implicit rules. The same goes for accessing error
or calling logInWithCredentials()
in an .authenticated
state. And beyond removing any defensive code, we can also remove the test cases that validated these implicit rules as well, since invalid combinations of property values and states are now impossible.
In essence, we have moved implicit rules that had to be tested and checked in defensive code into explicit rules defined in the types themselves. Which again allows the compiler to enforce them rather than requiring humans to remember and correctly handle these rules manually.
For just this simple example converting the protocol-defined interface into a model-defined interface with two distinct cases would allow us to remove at least 8 units tests and associated defensive code:
- When
authToken
should / should not be nil - When
error
should / should not be nil - When
logOut
can / can't be called - When
logInWithCredentials
can / can't be called
For readers who are familiar with Algebraic Data Types, it is mathematically demonstrable that these kinds of enum-based models require fewer test cases. Here's a brief explanation of how:
A struct is called a Product Type. This means that the total number of distinct values or variations of a struct type can be calculated by multiplying the possible values of its properties (the product of all its properties). So, for a struct like this:
struct AuthenticationModel {
let isAuthenticated: Bool // <-- Bool has 2 possible values
let authToken: String? // <-- String? has two possible values: .none or .some (not counting associated values)
let error: Error? // <-- Error? has two possible values: .none or .some (not counting associated values)
}
// Multiply the possible distinct values of each property and you get 2 x 2 x 2 = 8 *minimum* possible variations of AuthenticationModel (again, not counting the associated String and Error values themselves)
With a minimum of 8 distinct combinations of properties, you would need to write a minimum of 8 unit tests to validate the proper combinations.
Now let's look at the enum version. An enum is called a Sum Type. This means that the total number of distinct values of that type can be calculated by adding together its cases (the sum of all its cases). So for the enum version:
enum AuthenticationModel {
case authenticated(Authenticated) // 1 case
case notAuthenticated(NotAuthenticated) // 1 case
struct Authenticated {
let authToken: String
let logOut: () -> Void
}
struct NotAuthenticated {
let error: Error?
let logInWithCredentials: (Credentials) -> Void
}
}
// Add the cases and you find that there are 1 + 1 = 2 *minimum* possible variations of AuthenticationModel. Because we have removed optionality from authToken, even if we count the 2 possible values for error in the NotAuthenticated associated value struct, we still have a *minimum* of only 1(.authenticated) + 1(.notAuthenticated) * 2 (possible values for .notAuthenticated: 1 with .some error and 1 with .none error) = 3 distinct cases to test.
This is oversimplified and a little hand-wavy in the interest of simplicity and brevity, but hopefully you can see the mathematical pattern at play which suggests that simply by removing possible combinations of different properties in a struct and replacing them with fewer properties in distinct enum cases we are in fact provably reducing the minimum number of test cases which should be written for our model!
But wait, there's more — a final important benefit of enum-based models that simply cannot be matched by protocol-defined interfaces...
Self-Documenting API
If you've been coding for iOS for a few years, you know how painful certain APIs can be due to implicit, often unknown rules about what methods can be called at what time and in what order. Some classic examples:
UIViewPropertyAnimator
is a perfect example of the unsafe APIs we've been addressing. Itsstate
property has three possible values (.active
,.inactive
, and.stopped
) and depending on which is current, the different methods you call on the animator will either work correctly or crash the app. For example, you should not callstartAnimation()
if the state is.stopped
and if you do the app will crash. Unfortunately, if you've used this API before you know that the only way to fully discover all the implicit "rules" is through trial and error or trying to find good 3rd party blog posts about it.UITableView
can be triggered into a variety of crashes if you callinsertRows()
,deleteRows()
, etc. without first callingbeginUpdates()
and without finishing withendUpdates()
. But there is no self-documenting aspect to the code which suggests at all thatbeginUpdates()
should be called first and thatendUpdates()
should be called at the end.
Ultimately in both of the above examples (and many many more in both Apple and non-Apple APIs), what the developer is faced with is a long list of methods which will be suggested by autocomplete and can be called at any time. It's very difficult to understand which methods are safe or appropriate to call at a given time. Similarly, there are many properties which may be irrelevant or unpopulated in the the current context of an API, yet they are still all listed by autocompletion and accessible to call, which creates a higher amount of noise to signal and can cause confusion.
Enter once again model-defined interfaces and enum models!
We've already covered how this pattern increases safety and reduces the need for defensive coding and the number of required test cases. But as a last note, we will also examine how it takes self-documenting code to a whole new level and makes things much easier and clearer for developers.
Let's start with a simplified representation of the API for Apple's UIViewPropertyAnimator
:
enum AnimationState {
case inactive
case active
case stopped
}
protocol UIViewAnimating {
var state: AnimationState { get }
var isInterruptible: Bool { get set }
func startAnimation()
func stopAnimation()
}
And here are some of the implicit rules:
.inactive | .active | .stopped | |
---|---|---|---|
startAnimation() | works | works if isRunning == false otherwise does nothing | does nothing |
stopAnimation() | does nothing | works | CRASHES |
isInterruptible | works | CRASHES if set | CRASHES if set |
Unfortunately, these rules have only spotty documentation and require research or trial-and-error to discover. And the code / API itself is not at all self-documenting in regards to these rules.
So as an experiment let's conceive of a refactor to this API to make it an enum model:
enum UIViewPropertyAnimator {
case inactive(Inactive)
case active(Active)
case stopped(Stopped)
struct Inactive {
let startAnimation: () -> Void
var isInterruptible: Bool // Gettable and settable
}
struct Active {
let isInterruptible: Bool // Gettable / read-only so never crashes
let startAnimation: () -> Void
let stopAnimation: () -> Void
}
struct Stopped {
let isInterruptible: Bool // Gettable / read-only so never crashes
}
}
By converting this API into an enum with explicit states, we have managed to make most of these rules self-documenting (what is valid or safe to call in each state is made clear by the types themselves), and autocomplete will never suggest or show an invalid or unsafe property or method for the current state of the animator!
This makes it much easier and clearer for developers to understand and use. Comparing for example, an animator in the .stopped
state, the protocol-defined version of the API would expose 2 properties (state
and isInterruptible
) and 2 methods in the list of members provided by autocomplete, and the compiler would allow a developer to invoke any of them. However, one method would do nothing in this state, the other method will cause a crash, and 1 property would cause a crash if set! Therefore 75% percent of the exposed API is non-functional or downright unsafe to use.
Looking at the enum model version of the same API, the .stopped
case only exposes a single isInterruptible
property in a read-only form which is safe. The unsafe methods and the ability to attempt to set the isInterruptible
property don't even exist, will not show up in autocomplete and will not compile if called. So the developer clearly sees the only available information in this state, and the code self-documents the unsafe or non-functional members by removing them entirely in this context!
Implementation Details
Some readers may have wondered "if all of these model behaviors return Void
, how are changes to the model received?" This is a key implementation detail for the model-defined interface approach: it is ideally suited specifically to reactive code with unidirectional data flow. In practice, consumers of the API would depend on a publisher of instances of the model, for example:
struct AuthenticationView: View {
@State var model: AuthenticationModel
let modelPublisher: AnyPublisher<AuthenticationModel, Never>
var body: some View {
VStack {
switch model {
case .authenticated(let authenticated):
Text("Authentication token: \(model.authToken)")
Button("Log Out", action: { model.logOut() })
case .notAuthenticated(let notAuthenticated):
if let error = notAuthenticated.error {
Text("Error: \(error)")
}
Button("Log In", action: { notAuthenticated.logInWithCredentials(.valid) })
}
}.onReceive(modelPublisher) { model = $0 }
}
}
So it is up to some external code to publish a stream of the AuthenticationModel
which can be consumed by the view. That same external code would populate the different behavior closures within each instance of AuthenticationModel
(like the logOut
closure). Those closures would perform some logic and then result in a new value of AuthenticationModel
being published to the stream.
Summary
So in summary, model-defined interfaces are a reactive, unidirectional pattern for building safe, self-documenting and less redundant APIs. And they reduce the number of test cases and amount of defensive coding required as well! When compared to a tradition protocol-defined interface approach, they provide more benefits and less drawbacks across the board!
Protocol-Defined Interfaces | Model-Defined Interfaces | |||
---|---|---|---|---|
Boilerplate and redundant code | ⚠️ | Moderate: requires conforming multiple types to the same protocol and declaring the same protocol properties and methods in each conforming type | ✅ | No boilerplate or redundant code required |
Safety | ⛔️ | No safety guarantees — all methods and properties are visible and accessible even in unsafe or invalid contexts | ✅ | Compiler-enforced safety by limiting properties and methods to only those which are valid and safe for the current state |
Test cases and defensive code | ⛔️ | Require test cases to ensure all possible combinations of property values are valid, and that methods called in the wrong context fail gracefully. Similarly, requires defensive code to avoid executing methods in the wrong context | ✅ | Greatly reduce the required number of test cases and the need for defensive coding |
Self-documenting code | ⚠️ | Code can only be self-documenting for API that isn't stateful and has no context which can affect the availability or behavior of properties or methods | ✅ | Fully self-documenting for all kinds of API, enabling even contextual or stateful API to be clearly understood through autocomplete and the existence or omission of properties and methods / closures |
Further Exploration
If you're interested in seeing concrete example applications using model-defined interfaces and an architecture built from the ground up around this paradigm, check out Source Architecture on Github! And as always feel free to contact me with your questions or comments (or leave them below)
Posted in: architectureiossource architectureswift