II. Principles
Client-Side ArchitectureThis is page two of a guide on Client-Side Architecture basics. Start at page one.
The most influential client-side architecture design principles
While the clean architecture works, we don't need a copy of it on the client-side. However, I do think it's a good idea to look at the same design principles and practices that formed it and apply those to the client.
You'll notice that each principle, in some way, is about enforcing some structural constraints as to what can be done, and how things are organized.
In my opinion, these are the most crucial design principles. They influence 90% of what constitutes good client-side architecture.
- Command-Query Separation
- Separation of Concerns
Command Query Separation
Separate methods that change state from those that don't
Command Query Separation is a design principle that states that an operation is either a command
or a query
.
commands
change state but return no data, andqueries
return data but don't change state.
Operations are the same thing as interactions.
The primary benefit of this pattern is that it makes code easier to reason about. Ultimately, it urges us to carve out two code paths: one for reads, and one for writes.
The simplest way to see it in action is at the method-level.
Commands
Consider the methods createUser
and selectTodo
. These are both command
-like operations.
function createUser (props: UserDetails): Promise<void> { ... }
function selectTodo (todoId: number): void { ... }
Notice that neither of these methods return anything. They're both void
. That's what a valid command
is.
That means that the following methods aren't valid commands.
function createUser (): Promise<User> { ... }
function selectTodo (): Todo { ... }
Queries
Queries are operations that return data and perform no side-effects. Like these, for example:
function getCurrentUser (): Promise<User> { ... }
function getUserById (userId: UserId): Promise<User> { ... }
Why does it matter?
- Simplifies the code paths — this is what React hooks does with the accessor/mutator API of
useState
, and what GraphQL does withqueries
andmutations
. - Operations are easier to reason about — consider how hard (and disastrous) it would be to test a
query
was working properly if it always also performed a side-effect that changed the state of the system. - All features can be thought about as operations:
commands
orqueries
. If you want to make sure that all your features have integration tests, ensure a good separation ofcommands
andqueries
that the user performs, and test each one. One other interesting discovery: since most pages/routes in your app invoke one or more features, a potentially maintainable folder structure could be formed by co-locating all the concerns and components by features, and then by page/route. The folks behind React Router seem to be on a similar page (sorry); their new project, Remix, features file system routes and route layout nesting. - Apparently, cache invalidation is one of the hardest problems in computer science. It's easier with this. Using CQS, we can be sure that when if no new
commands
were executed (against a particular item), we can continue to performqueries
for directly from the cache. The moment acommand
is executed, we invalidate the item in the cache. Consider how this might be useful for a state management library.
Separation of Concerns
Consciously enforcing logical boundaries between each of the architectural concerns of your app
Assume we have a list of todos.
When a user clicks delete on the todo, what happens next?
export const Todo = (props) => (
<div className="todo">
<div class="todo-text">{props.text}</div>
<button onClick={props.onDeleteTodo}>Delete</button>
</div>
)
Well, the view passes off the event to a container. That could connect the user event to a method from a React Hook or a Redux thunk. From there, we might want to run some logic, decide if we should invoke a network request, update the state stored locally, then somehow notify the UI that it should update.
That's a lot. And that's a simple app. And when I said we might want to run some logic a moment ago, I wasn't clear about exactly what kinds of logic it could be. It could be authorization logic, validation logic, interaction/domain logic, etc. Instead of putting five different kinds of logic wherever, we can classify it, carve out a place for it to live, and be more structured and conscious about how we connect features together.
Separation of concerns is one of my favorite design principles. It's about thinking the jobs to be done, delegating them to a particular layer that handles those concerns, and then ensuring those layers do their jobs, and their jobs only.
How separation of concerns and CQS work together
CQS said that every feature is an operation. It also said that every operation is either a command
or query
.
This means that every feature cuts through several concerns to work.
I like to think of features as vertical slices that cut through the stack.
Features are vertical slices
When we add or change features in an application, we're modifying a part of the vertical slice for that feature.
Need to change the way the login component looks? No problem, you're going to add some styles to the presentational component in the presentation layer from the Login feature.
Need to change what happens a when todo open for longer than 30 days was just completed? Want to throw confetti on the screen and say how proud of the user you are? Gotcha. Add some logic to the xState model from the interaction layer for the Complete Todo feature.
I'm a huge fan of this.
Understanding the responsibilities of each layer enables us to better reason about which tools to use per feature.
Using Apollo Client, React Hooks + xState
- Application logic: Hooks + xState
- State management: Apollo Client (global state)
- Data fetching: Apollo Client
Using Apollo Client and plain JavaScript
- Interaction logic: Hooks + pojo-observer
- State Management: Apollo Client (global state)
- Data fetching: Apollo Client
Using REST, Redux, and React Hooks
- Interaction logic: Hooks
- State Management: Redux (global state). Connect for observability/reactivity, and Thunks for signaling async states.
- Data fetching: Fetch or Axios
I first heard of the term vertical slices from Jimmy Bogard. Thinking of features this way reduces the amount of time it takes for developers to figure out where to add or change code.
This is where developers get stuck, figuring out what the layers of the stack are, and which tools can be used at each layer of the stack.
Vertical slices enables us to keep Single Responsibility high if we "minimize coupling between slices, and maximize coupling in a slice" — via Jimmy Bogard. Also read Kent C. Dodd's article on "Co-location".
Why does it matter?
- Better visibility as to which tasks need to be done, which layer they belong to, and which tools can be used to address those concerns.
- Helps to decide whether we want to implement a layer ourselves or use a framework/library. For example, most developers won't build their own view-layer library for presentational components — they'll use React or Vue. But lots of users will build their own state management system from scratch using Redux and Connect.
Stay in touch!
Join 20000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. 🖖
View more in Client-Side Architecture