Introduction
In the previous articles, I shared some insights regarding why UI projects tend to become instant legacy.
Everything was boiled down to two core needs: instant feedback and proper design patterns. Where, in terms of design patterns, the requirement for hard separation between view and logic was emphasized.
I even suggested that Elm MVU was a way to go.
However, despite MVU being an architecture that allows for the hard separation of view and logic, I have become convinced that MVU (and functional programming for that matter) suffers from being somewhat alien to a "natural" process of thinking and programming.
By the word "natural", I mean something that correlates to the language we use in everyday life. Because functional programming can't always be described via such a language (e.g. despite monads (including Observable streams) being a relatively simple term, you won't be able to express it in such a language). I became convinced that programming that would better correlate to natural language is multiparadigm programming, where things are not strictly OOP and not strictly functional, but one or the other depending on clarity and the ease to work with.
Therefore programming of the core of the application (the model/domain layer) isn't really about right or wrong, the model behind an application is a description of how a person who wrote it understands the program conceptually, and it better be one person or a group who is on the same conceptual page.
In this article, I demonstrate a process of building an application that will have the necessary components of good architecture (according to Uncle Bob Martin) with some extra ones that I personally find valuable:
- General
- Testable
- Scalable
- Maintainable
- Follows SOLID
- Communicates a conceptual understanding of the creator
- Details
- Dependency Inversion
- Allows to postpone decisions of which tools to use
- Framework agnostic
- Allows to optimize for performance, security, and the rest at later stages of development
- Allows to postpone decisions of which tools to use
- Design as a single source of truth for the view layer
- Dependency Inversion
- Development process
- Outside-in
- TDD
But enough of philosophizing, let's dive into the paraphernalia. Shall we?
Development
In this article, I will demonstrate a process of creating an application in an outside-in fashion. Where the ultimate source of truth is the design in Figma.
We will then create a pure view as a function of state. It will not have any logic or state (other than a few details like scrolling unrelated to the domain logic).
The logic will be created in an OOP manner as a composition of classes via DI in a composition root. This will allow us to postpone details like the choice of storage and possibly some other details.
The Model will be connected to the view using a mapping function similar to ViewModel. It will have the necessary functionality to represent the view, but will not know anything about the details of the view (like a library used or DOM, etc.)
Having a ViewModel will allow us to write the application in a TDD fashion not worrying about complex view libraries/framework runtimes and even allowing us to swap these.
Because both the Model as well as ViewModel will be pure JS Objects (like POJOs), they should also be easily convertible to other languages.
It is important to remember that this approach is all about writing legacy-proof apps (legacy-proof = “adaptable to change” = scalable), which we argued require instant feedback (via Storybook and black-box tests in Jest for example) and good design patterns, which in our case is MVVM and DI.
Step 1: Designs
Here I explain how designs will be converted to Storybook stories.
Since the tools for conversion are still far from perfect, we should rely on ourselves to implement components for the first time. However, as we change something in the designs, we can ask LLMs to adapt the changes to what we already have in the component code. This is possible because components, when implemented correctly, tend to be rather small and easily understood by LLMs.
Here is the link to the Figma file.
Step 2: Storybook
Once we convert designs to Storybook we can use the components to represent scenarios by putting together sequences of pre-setup pages (with certain props), and since we know what props need to happen in transition given certain user interactions we are preparing ourselves for writing black-box tests.
The structure of the stories will resemble the following:
- Components
- Pages
- Scenarios
- A sequence of pages with various props for us to understand how props should change through interaction, which will allow us to write tests
- App
- The actual functional app with connected test doubles
- It is important to note that we don’t need to connect actual databases or any other IO other than the view
Step 3: MVVM & TDD
As we write our tests, we implement the domain logic.
I admit that I developed the sample application with very few tests and relied on the TypeScript type system for immediate feedback, so as a personal TODO, I will have to make sure I learn this practice, as I believe it ultimately saves a lot of time for larger projects like this one.
While our tests need to tell whether the functionality is correct, the domain logic structure itself is really not about right or wrong. The conceptual model behind an application is a description of how a person who wrote it understands the program conceptually, and it better be one person or a group who is on the same conceptual page.
As a philosophical side note, Immanuel Kant revolutionized philosophy by shifting the focus from the idea that we directly perceive the world as it truly is to the idea that we perceive the world as it appears to us through our own minds. This means we study our experiences of the world, not the world itself.
Similarly, when developing a program, we shouldn't aim for a single "correct" solution. Instead, we should aim to create a program that effectively represents our understanding and concepts. The quality of this understanding can vary, but as long as the program follows SOLID principles, is testable, works correctly, and is understood by those who use it, we have achieved our goal.
To illustrate, a program doesn't have to be OOP or functional, as in reality, if we could think like computers, we would write optimized binary code directly without any programming languages.
However, I believe that every developer has dreamt about representing their application as simple classes that read like simplified English.
Technically, MobX allows you to do exactly this - represent your model as simple classes. However, there is a price to pay, your classes have to be wrapped with decorators that allow for automatic reactivity. However, representing your application as simple classes doesn't mean you have to rely on yet another framework.
Au contraire, what MobX does still could be accomplished with just simple POJOs.
The View Model, in our case, is a step between simple representation and the view that is always connected to some framework (React, Angular, Vue, Flutter, etc.), but since it is not connected to a framework, and we can use it as a simplified representation of the view that we can actually can (and should) test. Because ViewModel in our case is the boundary, that will allow us to write tests from the intent perspective (similar to behavior-driven development), where the user clicks or interacts with something. This will then allow the frequent refactoring that is required for us to revise our conceptual understanding as often as it needs to be done.
So we will always have an opportunity to refactor as long as tests pass.
Composition root
It is important to remember that the ultimate detail of the application that will change the most is the composition root, where all dependencies will be combined.
It is important to demonstrate in the code repository how you assemble your application as transparently as possible. It means that whenever somebody looks at the repo and then looks inside the index file, they should be able to understand how the application is structured and what its intent is.
Link to the sample app composition root
Step 4: Connecting to IO
The last and the coolest part will be the ability to delay very hard decisions about technology for storage and other IO as far into the future as possible, thus allowing us to keep our pace and implement features while knowing that we still have time to make an educated decision based on our app and stakeholder needs.
Pros & Cons
- Pros
- Legacy-proof
- Easy-to-learn
- This approach builds on top of a lot of established practices like OOP, MVVM, and Component Composition
- Natural programming style
- Cons
- Requires you to form a coherent conceptual understanding of your application
- You will have to doubt, rethink, and refactor
- There is no set algorithm for creating a model, you will have to experiment until it fits your needs
- Frequent refactoring
- Simplify and optimize the model
- While View and IO could be optimized separately,
- It requires you to make sure your model is as simple as possible
- Requires you to form a coherent conceptual understanding of your application
Conclusion
This article was a demonstration of how applications can be written so they are kept legacy-proof and are adaptable to change as true software should be (software means that it is soft or malleable and adaptable to changes).
This is a refreshing view on UI development as modern UI is always 100% tied to a particular framework, but as this article suggests it should not be the case, and in fact not marrying it to a framework makes the code look much simpler.
Useful links
Sample Application: The application that was created to illustrates concepts in this article
Clean Code by Robert Martin: The famous book that explains core principles of scalable software
Dependency Injection Principles, Practices, and Patterns: A book that I consider to be the practical implementation of the concepts outlined in "Clean Code"
Top comments (0)