This story is a follow-up on A domain-driven Vue.js Architecture, where we introduced an architecture that’s based on Business Domains rather than technical concepts like components or store. If you haven’t already, we recommend to read it first as we will build upon that foundation for this one.
A Vue.js app typically needs to do a couple of things, no matter what:
- Implement a whole bunch of Business Logic
- Offer a set of components that make sense of said Business Logic
- Style the app and come up with a good looking UI and great UX
One of our current apps is a somewhat special case, in that the client we are developing for is sort of a middleman. We do not deliver one particular version of that app, but instead tailor it to the unique needs of several companies that in turn are our client’s clients. From now on, we’ll call them consumers for convenience’ sake. This specific scenario entails a number of other requirements in order to make it reusable across different consumers:
- Offer one core set of functionality
- The possibility to adhere to any corporate identity, especially its visual identity
- Offer a limited number of themes a consumer may choose from. Themes may differ slightly in functionality, but essentially provide a different look & feel to the core
- Extensibility on top of and independent from the core, for each consumer
- The ability to change parts of the core for any consumer without breaking or affecting the remaining parts
We’re basically talking about a white-label product with extra steps. Making all of this work well was an exciting challenge. We soon realized that developing for reusability increases the workload significantly, which proved to be especially true for the initial setup of the project’s whole infrastructure. But it was to be expected. The project’s roadmap envisages a prolonged ramp-up phase with diminishing low efforts for each additional consumer.
- Augustus. A couple of years BC.
This translates to make haste slowly, and we wholeheartedly agree on that.
1. The core
Remember the architecture diagram of our previous story?
Let’s have a look at it again:
You are looking at the essential idea behind the core’s architecture. Together with our client, we evaluated each business requirement and made sure to only include functionality in the core that qualifies as useful to every single consumer. This is the baseline for each specific evolution of our app. We proceeded to translate each requirement to a Business Domain in our architecture. In this example, those are Homepage, Profile and Settings.
If we zoom into the UI layer of the vertical slice Homepage, we’d traditionally see something like this. A
Homepage.vue Single File Component that neatly colocates
<style>. Later on, we’ll adjust this classical notion of a SFC slightly to our specific needs. Just keep in mind that right now, this is already a fully functional application and most of the time, you wouldn’t need to change a thing. What’s missing are means of individualization. Over the course of this story, we will enhance the architecture diagram so that it meets all of the above requirements.
2. Corporate Identity
There comes a point in time for most companies when the need arises to establish guidelines for their corporate identity which might or might not result in a design system of some sort. You can put an incredible amount of work into such guides and systems. You can go really deep into how your brand should sound like in which scenario, you can explain the specifics around accessibility or you can define your brand’s iconography. There’s a million things to consider. Karri Saarinen wrote a beautiful article on building a visual language for Airbnb, that only deals with the visual part of things.
We needed to reduce the surface area of CI guides that we interact with. When you’re developing one thing but that one thing needs to be adaptable for a myriad of consumers, you simply cannot take each and every CI detail into account without breaking the idea of having one shared core. What that means in practice, is that we let each consumer choose a couple of things which they then have to provide:
- Brand logos
- A hero image, preferably as SVG
- The iconography as SVGs
- Between 1 and 2 fonts to be used across the app
- Translations for each desired language the app should be available in
We do provide fallbacks for all of those in case a consumer does not want to configure everything. While this might not satisfy all the requirements a CI guide will call for, it does already give you a lot to work with.
This results in the need to almost over-communicate those technicalities to our client and for them to do the same with their consumers. When declaring reusability a first-class citizen, this impacts future product decisions. In this context, further customizations are of course possible— it’s simply a matter of money and time.
CSS-in-JS triggers numerous heated debates. We won’t go into all that as it’s been written so much about already. If you want, you may jump into this rabbit hole, starting with an article about differing perspectives on CSS-in-JS by Chris Coyier that also points to further resources on that matter.
Vue.js actually provides its own CSS-in-JS solution in the form of Single File Components. The React community on the other hand came up with its own ideas around the problem. This is where we see fundamental differences between React and Vue come into play: Where Vue as a framework offers an all-in-one solution, React’s community was pretty much forced to develop the flourishing ecosystem that it is today by themselves since React itself is just the UI library. Two popular options that emerged are styled components and emotion, among others. And as it turns out, there is an emotion adapter for Vue as well which ultimately became our tool of choice.
Here, you can see the
theme in action. Once provided at the app’s root level, you can access the fundamental visual building blocks of your application anywhere. Also, notice how styles are no longer bound to classes but instead manifest in the form of actual Vue components with speaking names — hence the name styled components.
Our zoomed-in UI layer of the Homepage slice now looks a bit different. We got rid of the
<script>. Notice that we did not move them out of the UI layer, only within. We’ll go into more detail around how we take advantage of this refactoring in the following chapters.
Me and others chimed into one of the current Vue RFCs regarding scoped style improvements, where we champion some of the advantages that libraries such as styled-components or emotion bring to the table. IMHO it would be incredible if such functionality would be baked into Vue itself. If you feel strongly about what’s to come to Vue, this is your chance to take part in that conversation.
3. Theming our app
As mentioned earlier, the following equation applies for this story:
theme !== theme. Or in other words, CSS-in-JS libraries use the same naming conventions for their global
themes as we do for our app’s themes, when in reality, there’s a big difference. When we talk about themes, we refer to all of our app’s CSS as well as distinct components that may differ between different themes. This will become clearer when we update our architecture diagram for the first time. For this, we introduce a new concept called Container. You should be familiar with the Core by now, which is put into its own container. Additionally, there’s now a new Theme container which accommodates any number of themes.
In this first extension of our architecture diagram, styles moved from the UI layer of each functional slice into the UI layer of each theme. Hence, the core is left without any styles. The core’s sole function is to define what all the components are supposed to be doing and not how each component shall look like, apart from their semantic structure. In addition to the fundamentals like colors, spacings etc., we can now express unique stylings per theme with the full power and flexibility of CSS. The entire visual appearance is subject to reshape which allows each theme to be a completely different experience that goes beyond a simple paint job that solely changes brand colors — while guaranteeing that everything still just works™ since there remains exactly one truth to our components’ functionality, defined in the core.
What’s best is that consumers may now be presented a wide range of different visual concepts, alongside their brand colors and remaining visual identity in virtually no time with only little more effort than the flip of a switch. This is where consumers can choose a visual baseline on top of which we may fulfill possible individual wishes.
Assigning the right styles to the right component
<style> tag of your SFC.
On the flip side, this increases the distance between your component definition and its associated CSS. Collocation of all the relevant bits is one of the original selling points of components. When done properly, components turn into truly self-contained entities that handle rendering & styling, data requirements and corresponding business logic all by themselves. Our approach makes you lose some of those advantages in favor of reusability. Sometimes software development is about making tradeoffs, and in this case reusability trumps the convenience of collocation.
Now, how exactly does the marriage between the
Core and any one of the themes take place? For that to happen, we make use of a code sharing concept called Higher Order Components (HOCs) as well as webpack’s require.context functionality.
HOCs are probably best explained with their mathematical equivalent. Imagine implementing two functions
f: x => x and
g: x => 2x. After a while, the requirements for your functions change: All functions’ results need to be increased by
1. Sure enough, you update your function definitions to
f': x => x + 1and
g': x => 2x + 1 respectively. Though, instead of doing the work manually for each function, you could have chosen to write a new function
h: x => x + 1, and call it with your functions
f': h(f(x)) => x + 1,
g': h(g(x)) => 2x + 1. In case you had 1000 functions instead of just 2, implementing that new function
h may just be the reason you were able to leave work early that day. As a matter of fact, you can apply the same principle to Vue components. You can write a function that takes a component, modifies it as appropriate and returns a new component with said modifications. This CodeSandbox illustrates the concept:
This is a HOC
withBox that adds a new component
Box to a
baseComponent, much like
+ 1 to a function.
We are excited about the Composition API Vue@3 will bring. Higher Order Components, while being cool and all, carry their own disadvantages. They are kind of a foreign concept in the Vue community on top of that. In the example, what would happen if App registered its own Box component? We’re confident that the Composition API will allow for more elegant solutions to this sort of problem.
require.context, it’s first of all important to note that it is not part of the ECMAScript spec but something that comes along with webpack. Which means that this function is run during build-time, not runtime.
For our tests that aren’t aware of any webpack magic, we’re using a babel plugin.
require.context is great for recursively
requireing a whole bunch of dependencies at once, matching an arbitrary RegExp, starting at a pre-defined root directory. In our case, we may define a context for our theme as follows:
This requires all files, starting from
THEME_ROOT_PATH, that end with
THEME_ROOT_PATH is a constant defined with webpack’s DefinePlugin.
This in turn accesses an environment variable
THEME, defined with cross-env in our
package.json, which is the single point in all of our code that lets us control the visual appearance for a consumer. It is actually just one flip of a switch.
Putting all of this together, we end up with a HOC
withStyles that takes a Vue component
MyComponent.vue, searches for its style implementation
MyComponent.styles.js of theme
THEME on the basis of
name and finally extends its registered
components by all the styled components found in its style implementation, much like the HOC example above. That’s why, in the example below, we use
Box in addition to the regularly registered component
Further, we create a custom key
styleInterface, which serves kind of a similar purpose as the local registration of components. It is an array of Strings, indicating what exact styled components are expected to be implemented by each theme. We use this in order to throw warnings during development in case a theme is missing some style implementations, as well as having an in-file overview of what components are at our disposal in the first place.
Different themes may intend functionality that differs slightly from the core’s implementation. Sometimes, you run into problems that CSS alone cannot solve. Take a display component for a couple of items, for example. Most themes might want to display items in a grid while another one assumes a slider that goes through them, highlighting one item at a time. A first naïve implementation could be to have a boolean prop
useSlider, which toggles between a grid display and a slider display. Maybe a prop
display would be even better: Just choose between a number of display modes! One problem with this is that inevitably you end up with ginormous components that do way too many things at once. Instead of asking: “How can I make this component do one more thing?”, the oftentimes better question is: “Should another component solve this thing?”.
Do one thing well.
Yeah. The irony is not lost on us here. The even more severe problem though, is that we are dealing with a defined core that holds all of our component definitions. It’s only the style implementations that may differ per theme. If you changed the suggested
useSlider prop to
true, this change would apply to all themes. All of a sudden, everyone would get the slider view instead of just that one theme.
To deal with this fixed state situation, we introduced a concept that we call Extension Points, a term originally introduced by UML. In our example case, we create two components
SliderView.vue. Assuming that the grid view is the default, we would go on and put that component into the core.
SliderView.vue however would find its place in the respective theme’s UI Layer. Now, this is where the magic happens: each theme implements a file
extensionPoints.js which assigns concrete components to a number of well-defined Extension Point Components, like this:
All other themes may decide to use
GridView.vue instead. You can even go further and have the Core define defaults for each extension point, then merging it with a subset of overrides each theme may specify.
Now, all that’s left to do is to loop over the
extensionPoints Object and register those components globally:
This makes them available everywhere in your app’s templates with their generic identifier, e.g.
<ItemView-ExtensionPoint />. Notice how this elegantly circumnavigates the initial problem of having one fixed core that must not be modified.
Each extension point may in fact implement almost anything. Just be aware of a couple of gotchas:
- To be fair: The more extension points, the more confusion arises. We carefully decide if it’s really necessary to create another one. It might just be that the existing functionality is good enough.
- Be careful around each extension point’s public interface, or in other words: its props. Once a new implementation adds new props, each of its invocations now has to be examined. After all, those new props have to be added somewhere, otherwise they’d be pretty useless. All other implementations do not know about those new props though. They’d get passed to them without any effect. Sooner or later, this turns into a hot, confusing mess. We found that it’s best to keep the interface stable between all the different implementations. Let each of them accept the same set of props.
- It’s not always a clear-cut choice when deciding where to insert an extension point. Let’s say there is a component
Awhich internally uses components
D. You could choose to replace
D', deep down the component tree to just very specifically alter one leaf. Or, you could choose to create an extension point higher up by replacing
A'then uses the exact same
C, and on top of that uses a new private component
D''that is only just imported by
A'and never globally registered anywhere. At first sight, the former seems more reasonable: tackle the problem in its most contained form to not affect too much else. While that’s a fair assessment, we noticed that in some cases, it’s better to do the latter. It gives you the flexibility to introduce even more changes later on. So for components that are likely to be altered in various ways for different consumers, you may want to choose a branch higher up in the component tree.
4. + 5. Extending the app & Modifying the core
Let’s update the architecture diagram one last time:
A new container
Consumer appears. Now, we simply apply all of this story’s ideas so far to our new container. That’s right. Consumers may also specify
.styles.js files, as well as their own extension points. We end up with cascading containers.
Consumer take precedence over
Core. This makes us fulfill individual wishes on a consumer basis. Our 5th requirement to change parts of the core for any consumer without breaking or affecting the remaining parts is thus fulfilled.
On top of that, this is where we implement one-off solutions that only one specific consumer commissioned. Maybe
Consumer 2 requests one of those universally loved live-chat integrations: “Hi, my name is Dave. If you’ve got any questions, you can always find me in the bottom right corner :)”. In that case, we would create a new Business Domain
LiveChat with our layers
Connector within the vertical consumer-slice
Also, let’s update the
scripts excerpt of our package.json:
Here we define a different build script for each consumer. In addition to the
THEME variable, we specify another variable
CONSUMER that essentially serves the same purpose, only for the
Consumers container this time.
Reusability is kind of the final boss of software development. This story illustrates some of its ramifications: everything becomes more complex as soon as something needs to be reused many times. At the same time, we’re happy with our outcome. Without any structure and without any well-defined rules for our codebase, things would get out of hand pretty quickly.
All of this is of course rather specific to our project. We did our best to generalize our ideas as best as possible while still providing concrete examples. Hopefully, you found some ideas that inspire you for some of your projects.
The experience of working in this project does not quite resemble the experience of jumping into a project freshly bootstrapped with Vue CLI. The more complex thoughts flow into a project, the more crucial it becomes to document the main ideas. I guess that new hires could simply go through our series of blog posts. On the other hand, often there are many small technical details that are not particularly interesting to the broader public but still vital to the understanding of our code base. Stay tuned and follow our publication, where we will document our documentation process soon. Also, we’ll go into detail on how we structure our tests around this architecture and how to make sure to not accidentally introduce unwanted visual changes across all of our consumers.