Viper Does Not Spark Joy
Ever had the feeling that VIPER should be simpler?
Architecture
Architecture in software development is fundamentally about facilitating change—whether adding new features, fixing bugs, or adapting to new requirements. It includes any work done during the lifecycle of the code, including typing, testing, documenting, understanding, debugging, and modifying.
Your work as a programmer is writing the code and structuring the resulting complexity to provide a high-level view. That is, you create the engine pieces, then hide the engine under the hood, exposing just the accelerator pedal. Correctly done, this divides the application into layers of abstraction separated by well-defined interfaces. Low-level details can be ignored, while high-level components are ready to be re-composed in different configurations.
Styles
VIPER, MVC, and others identify recurring components in software development and dictate how they should communicate. These styles are based on two fundamental principles:
-
Distribute responsibilities in entities with strict roles. A container’s name should reflect its contents. If it doesn’t, you’ll waste time having to read its contents to find out what it does.
-
Unidirectional dependencies. I use the hammer, but the hammer doesn’t use me. Otherwise, it’s a sentient hammer and thus, not accurately named.
Understanding VIPER
Basically this:
As long as arrows point one way only, there will be no screw-ups like the network client telling the router to navigate to the login screen. For why this would be bad, see points one and two above.
The view is a particular case because it displays data and accepts user input. Because of this, view and presenter need bidirectional communication. This is typically managed through protocols or closures that allow for flexibility without direct dependency:
Or one element may offer a closure that the other can set to receive events.
You may wonder:
-
Didn’t you say a double arrow is bad? View, presenter, and logic never talk back to each other, they participate in a circular input/processing/display flow. For practical reasons, the view is two separate things: a display with a keyboard.
-
Why is the view the root of the object graph? Given that the view is the element deallocated by UIKit when we navigate away, it’s also the element that strongly references the presenter. Instead, if you wanted your logic to own the graph of objects, you would have to overriding standard UIKit behavior, which is complex and generally not advisable.
I would replace the router with a coordinator to centralize navigation code and avoid coupling to other screens. Eventually, you may want to launch screens in isolation to iterate UI edits faster.
As for models, you often have three for isolation: entity, domain, view model, with mapping between them. This separation, along with mapping between these models, helps in maintaining clean architecture and facilitates easier maintenance and testing.
Design
In summary, Viper has an SRP (single responsibility principle) architecture, a discussion of the responsibilities involved, and the novelty of saying: every screen should have these elements because you’ll probably need them.
Any architectural style is rooted in basic design principles (SOLID, GRASP, etc.) and provides consistency. However, merely adopting an architectural style like VIPER does not guarantee success. Misapplication of these styles still leads to significant issues.
Problem. A common anti-pattern is to update individual view elements from multiple points. This is prone to errors and hard to debug because it defines your view as a function of a sequence of events, leading to race conditions.
Discussion: What design principles can we use to fix it?
- From Basics of the Unix Philosophy
- Rule of Transparency: Design for visibility to make inspection and debugging easier.
- Rule of Representation: Fold knowledge into data so program logic can be stupid and robust.
- From Martin Fowler’s Split Phase: read all the data, process all the data, then update all the data.
And two more without catchy terms:
-
Concurrency is hard. Performance is only a problem when there isn’t enough, so go serial by default.
-
Imitate pure functions. Even when objects encapsulate state, you want them to behave deterministically, producing the same output for a given input or at least a replayable history of side effects.
Solution. So with all this in mind, the solution is to update the view through a single point, with a single object: view.update(model)
–or bindings if you prefer. Now the view is a function of the state.
Takeaway point: you need design principles. They are the pillars of architecture and operate within its boundaries. Without them, we lack criteria and misuse architecture. Passively following an architecture style is not by itself architecture.
Next, we’ll revisit Apple’s MVC with an active attitude.
* * * *
Apple’s MVC
Apple didn’t design key elements of the iOS architecture for complex apps. This is fine. It keeps things simple for the common case and it’s straightforward to fix. I’ll give you two examples.
A Better Delegate
Writing a complex app that uses most of the app delegate will grow the delegate to thousands of lines. If that’s the case, the workaround is easy.
To refactor massive app delegates:
- Create a different object per responsibility (Analytics, Notifications, etc.).
- Conform them to a protocol extended with empty implementations (ApplicationService in the example below).
- Override the appropriate methods for each object.
- Delegate the calls from the AppDelegate to each service.
You may wonder
-
Should Apple provide those objects as AppDelegate variables? No. There would be use cases they can’t possibly anticipate, and some delegate methods participate in more than one of them.
-
Should Apple provide this exact design? No. If I’m developing a fart app, I don’t need the extra indirections for the sake of correctness. And it certainly wouldn’t help newbies learning iOS.
A Better View Controller
UIViewController has ten responsibilities out of the box, but they are either lifecycle calls or display preferences, so they are pretty much views.
To refactor view controllers:
- Treat them as views: compose controllers and reuse them across screens.
- Move non-view stuff elsewhere.
Writing a 2000-line class to observe, download, persist and transform data blatantly breaks the single responsibility principle. It’s our job to recognize and fix this, not Apple’s.
Template architecture
Most software written is an ad hoc solution with the minimum number of elements needed. It doesn’t follow any named style. However, this requires developers to make numerous design decisions independently. If a team says things like “with Viper we don’t have to think”, or “let’s all do things the same way”, it means they are not comfortable with ad-hoc design and need a template architecture like Viper.
Viper is easy: use the same elements, don’t design anything. Here are these boxes, put your code in one of them. This is NOT wrong: use Viper if you have to.
To walk my own path I need intuition. Intuition is the quick, gut reaction that integrates past experiences, knowledge, and reflection both at high and low levels. It helps in making swift decisions when outcomes are not easily quantifiable. Developing such intuition requires extensive experience, continuous learning, and experimentation.
While pursuing an intuitive, ad-hoc approach can be enriching, it comes with its own set of challenges. Mistakes are inevitable, leading to slowdowns and increased complexity which can be frustrating and difficult to navigate. Recognizing the gradual accumulation of these errors is tough. Errors don’t slap you in the face, they kill you by a thousand cuts.
There are no shortcuts in developing effective software architecture. Just as one cannot shortcut the educational process, developing a robust intuition and understanding of software design takes time and patience. The allure of quick solutions like VIPER is understandable, but true mastery requires a deeper engagement with the principles and practices of software development.
Wisdom takes time to accumulate. Otherwise, can you imagine? Kids, I have decades of experience being an adult and I’m quite articulate. I’ll explain growing up and today you’ll go back home as adults.
* * * *
Viper
Benefits
Viper constrains your environment to a coding standard:
- Each element has a single responsibility.
- Every screen has the same five components: (view, presenter, interactor, router, entity). This consistency lets people find things.
Problem: One Size Design
The goal of OOP is to decompose a problem in entities of the domain. For instance, in a design for a parking lot there are objects like ParkingTicket, TicketScanner, etc. Nowhere in the domain there is something called interactor. What does interactor even mean?
Because mobile applications are often glorified tables and entry forms, Viper takes a one-size-fits-all approach to design. It tells you to place your code in one of five boxes: view, presenter, interactor, router, entity. No thinking is needed, just choose a box.
Does it work? Often, it does. You probably need those five roles per screen, and the problem may be small enough not to require specialized objects.
But sometimes it doesn’t. Programming is a wicked problem where each feature is different and needs a specific solution. Eventually, you’ll get:
-
Too much per element (high coupling, low cohesion). A complex screen may require fragments of unrelated logic that are better suited as different objects. Then you have the work of turning a massive interactor into a front for additional objects.
-
Too little per element (low coupling, low cohesion). What is the presenter adding here (view → presenter → logic)? Shouldn’t the view talk with the interactor and router directly? Or better yet: how about you create elements as you need them? Creating a middleman for consistency shake is being consistently bad.
A symptom of low cohesion is the developer having to debug its way through the app because entities at the same level of abstraction are artificially spread. You no longer understand elements on its own, because part of them is elsewhere.
Problem: Bloated Implementation
There are five Viper objects in most Viper implementations, plus a protocol per component —each element in a folder, plus additional elements: builder, view model, mapper.
On top of that, a design system needs a screen composed of modular elements. If you make each a Viper module, the number of files will leave you thinking there must be a better way.
The book “App Architecture” had this to say about Viper, a “misguided” [sic] pattern they didn’t cover:
Attempts to bring “Clean Architecture” to Cocoa usually claim to manage “massive view controllers,” but ironically, they do so by making the codebase even larger. While interface decomposition is a useful approach for managing code size, we feel it should be performed as needed, rather than methodically and per view controller. Decomposition should be performed along with knowledge of the data and tasks involved so that the best abstraction-and hence the best reduction in complexity-can be achieved.
Before that, Kent Beck defined simple design in 1990:
- Passes the tests
- Reveals intention
- No duplication
- Fewest elements
This controller has 200 lines (4,5 screens). Can you describe what’s going on?
How about now?
Viper is a uniform way to achieve single resposibility and testing. Unfortunately, it also dilutes the programmer’s intention because it taxes the reader’s working memory with lots of abstractions and indirections. Good ingredients, mediocre stew.
Problem: Test-Induced Damage
If you are already doing Viper, consider some tweaks: Why are you writing a different protocol per component?
- Instead, model communication as an I/O exchange of data (example) reusing the same protocols. By imitating a pure function, your object reaps the same benefits. It isolates side effects from logic, reduces coupling to external components, and allows memoization.
Why are you using protocols at all?
-
To provide a second implementation one day. Don’t design in advance for the neds of tomorrow.
-
To decouple components. In any sane architecture, dependencies should go one way. It doesn’t mean you have to decouple with a protocol, you may offer closures.
-
To have a public interface for an object. If you look at the object’s public methods in your IDE, you already have it. That’s a contract too.
-
To unit test. For testing, you need a deterministic environment. There are several ways to set up one:
-
Mocks. Create a protocol for the public interface of an object, create a mock as a second implementation. Anything depending on the object can use the mock instead for testing purposes. Bad idea: more complexity in the system. Having protocols to inject mocks is test-induced damage, and a coupling code smell.
-
Communicate with values. Are you using method calls to signal user interaction? Replace them with values at your component boundary. You may know this from Gary Bernhardt but it’s also an old one. This is the way; no mocks needed!.
-
Integration testing. e.g. a test database, and pre-recorded network responses. Of course, this is not a unit test, plus the possible paths that may affect your tests are unthinkable.
-
* * * *
Final Thoughts
Bloated architectures like Viper are a technical solution to a social problem: we don’t trust people to design correctly, and we don’t know how to teach them. Consequently, teams settle for what they perceive as a safer, albeit mediocre, solution. Over time, this approach leads to a misunderstanding among developers, mistaking these constrained practices for professional norms.
Niccolò Machiavelli once observed
Men almost always walk in paths beaten by others and act by imitation.
This insight resonates in the world of industrialized software development, where big teams of different skill follow established patterns like VIPER. However, these should be viewed as temporary aids on the journey towards more independent and creative thinking.
For those who favor VIPER, challenging yourself to step away from it can be enlightening. Straying from the familiar path allows you to encounter and learn from mistakes, ultimately leading to more thoughtful and effective software solutions. Dare to go off-road, every mistake has something to teach you.