Improving design systems with SOLID principles. Part I: Separation of Concerns

Ismayil Khayredinov
ITNEXT
Published in
7 min readMay 2, 2021

--

Not so long ago, should anyone mention a “design pattern” my mind would wander off to plotting Scottish kilts and Polka dots, but the more I deal with and scale real-life interfaces, the more convinced I become that these principles not only have their place in front-end development, but should in fact define it as much as they do back-end development.

User interfaces should be resilient to change, and it shouldn’t matter if the wind blows from the tail or the head [pun intended] — adapting to changing product requirements and design needs should be a painless developer experience. SOLID principles can help us build better design systems and component libraries by applying established guidelines to engineering future-proof and testable front-end solutions.

I won’t go into the details of defining what SOLID stands for, as there had been plenty written on the subject: here, here, and here. I would like to focus instead on what I have learned from my mistakes, and the rules I have established to govern my work.

For the sake of brevity, I will not illustrate these ideas with code, and will instead continue this chain of thought in series of articles focusing on the assumptions I make here.

Separation of Concerns

Front-end applications at large are burdened with a variety of responsibilities, and often have too many reasons to change. When approaching a design system from a SOLID perspective, we should look at the different layers that comprise our application, and find ways to split out or abstract the logic to satisfy single-responsibility principle and the other 4 in equal measure.

Inversion of control

  • Dependency Layer: Numerous npm debacles we have seen over the years are a sufficient proof that while we should avoid reinventing the wheel, we should also have an option to replace the wheel if it gets flat. Our libraries should be designed in such a way as to allow substitution of dependencies without a full-scale refactoring mission.

Design system components should not rely on third-party dependencies directly.

  • Service Layer: Wherever possible, service singletons should not be imported from other modules directly as it complicates testing and keeps the application coupled with a single concrete implementation. Instead service dependencies should be inverted or injected.

Design system components should not import service singletons directly.

Error Handling

  • Error Handling Layer: Errors unfortunately are a natural part of every application, and they vary in their severity and effect on the application and the user flow. While error handling is important, individual components should probably abstain from catching and handling errors, as they should remain dumb and unaware of what logic the application and its parent components use for handling and reporting errors. Error handlers should either be injected into the component with props, or they should be caught at a higher level. Components should not be aware of their runtime environment, hence logging to console is also a no-no.

Design system components should not attempt to handle errors on their own.

Fetching and propagating data to/from external services

  • Transport Layer: Communication with external services should not be tied to any specific client implementation. Whether you use fetch or axios, or whether you communicate via web sockets or HTTP, should not affect other parts of the application.

Design system components should be oblivious to how the application communicates with the rest of the world.

  • API layer: It is common for external services to change their specifications, add or modify call endpoints and signatures, alter response codes and more. A design system should not make assumptions about API implementation details, and should provide a sufficient level of abstraction. Whether the backend uses RESTful API or GraphQL shouldn’t matter to individual components.

Design system components should not know where the data is coming from.

  • Transformation Layer: Data models change, newer versions of the API specifications go live, and often require that front-end applications adapt to these changes. Front-end models should therefore not be coupled with back-end DTOs to avoid drilling changes into the component tree.

Design system components should not care about the format or the scope of the external data flows, and should only consume what they need in a format they prefer.

  • Synchronization Layer: Dealing with asynchronous data is the day-to-day pain of most applications. Fetching, refetching and caching should be handled by a dedicated layer inside your application and components should either receive resolved data or be responsible for pending the rendering of child components. And as stated already, components should be independent from libraries being used for handling asynchronous data: useSWR, useQuery, useAsync etc all do the same thing but all have their caveats and components should not have to deal with their quirks.

Design system components should ideally be stateless, synchronous and controlled .

State management

  • Storage Layer: Components should be able to exist in isolation and not be tied to the specifics of global storage mechanism. Consumers could be using Redux, Recoil, Context or anything else to manage their state. As suggested earlier, components should try to stay stateless and be controlled from the outside through containers and/or higher-order components. State mutations should take place via handlers injected into components with props. As it stands, components should not even be aware that they are mutating something — they should simply use callbacks to dispatch events to the outside world, and what happens with these events is not of components’ concern.

Design system components should not have direct access to global application state.

  • Caching Layer: Local storage is not universal and may not be available in the runtime environment. Despite of many storage mechanisms available in the browser, applications should not rely on their availability or accessibility at all times (due to privacy preferences, vendor inconsistencies, size limitations etc). Caching should be optional, and should always take place outside of the component scope.

Design system components should not have direct access to cache.

Runtime Environment

  • Runtime Layer: Modern applications are built, rendered and run in various environments: browsers, headless browsers, SSR servers, CI/CD environments. Design system should be universal and not target any one specific environment or rely on the availability of runtime-specific APIs. A window is not always a window.

Design system components should not make assumptions about their runtime environment.

  • Viewport Layer: Smart watches, mobiles, tablets, desktops — design systems should work across a variety of screen sizes. It is probably not a good idea for a single component to try and adapt itself to the size of the screen — they should either be instructed from above, or the design system should provide multiple components suited for various screen sizes. Charging components with providing functionality and adapting to various screen sizes bloats the scope of their responsibility.

Design system components should be responsive by design while remaining agnostic to the size of the viewport they are being rendered in.

  • Animation Layer: Hardware acceleration is a tough nut to crack. In certain cases, no animation is better than a choppy one. Noone cares for animations on a screen reader. Besides animation should be contextual and fit the overall flow of the user experience.

Design system components should not perform their own animations.

  • Accessibility Layer: Mouse, keyboard, touch screen, reader — navigating the application and interacting with it should be possible on a variety of device types. Components should use semantic markup and follow ARIA guidelines to ensure maximum compatibility, accessibility and interoperability.

Design system components should be accessible on all types of devices without knowing what type of device is used to access them.

  • Localization Layer: A design system should not become a bottleneck for localization and internationalization. There is no reason for a component to prefer English over French, inches over centimetres, or confusing and frustrating people with other regional nuances and peculiarities (looking at you, US of A).

Design system components should work seamlessly in all locales without knowing what locale they are used in.

DOM

  • Hierarchy Layer: To ensure composability, components should not make assumptions about where in the DOM they are mounted and where they are located in relationship to other nodes. For example, adding margins to individual components is in violation of several SOLID principles.

Components should not be aware of their position in the DOM or know of their siblings or parents.

  • 3D Positioning Layer: Stacking of elements in the CSS tree should be centralized and dictated by the needs of the application, and not presumed by the type of the component.

Components should not manage their own z-indexes or be aware of where in the stack they are located.

Presentation

  • Composition Layer: A design system should provide a set of primitives that can be used to compose other components, which can then be used to compose further components: atomic design principles are a great starting point for ensuring that the components are reusable.

Components should be able to (re)arrange their children in any arbitrary order.

  • Styling Layer: Composed components should be able to represent their composition and state (e.g. disabled, active, invalid) to the external world (e.g. using Block-Element-Modifier class system), however they should not have any internal inline styles, as that would violate multiple SOLID principles. Components can adapt their composition based on their variant, but they should not have the slightest clue how that variant translates into their visual appearance. A consumer should be able to switch off all the styles (e.g. on a screen reader) and still make sense of the application.

Components should be able to exist without any styles, and should not care how they look to the outside world.

  • Theming Layer: Appearance decisions should be made at the level of design system tokens. Changes should cascade to an entire component library or its subset without any additional changes needed inside the components. You should be able to rebrand your business by only changing a few variables, hence blindly following examples in hyped CSS/JS frameworks will complicate your life in the future and will not make your library usable outside of your own organisation.

Components should exist in a black box without any knowledge of colors and absolute units of measurement that exist in the outside world.

Conclusion

This will be it for today. I have started working on some code to illustrate these principles and will share it as the time allows. If you disagree with any of my conclusions, feel free to leave a comment and let’s deliberate together. However subjective these posits are, I think they do reflect real-life challenges I have experienced over the years.

--

--

Full-stack developer, passionate about front-end frameworks, design systems and UX.