II. Principles

Last updated Jun 24th, 2020

This 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, and
  • queries return data but don't change state.

img/blog/client-side-architecture/Frame_44_(2).png

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 with queries and mutations.
  • 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 or queries. If you want to make sure that all your features have integration tests, ensure a good separation of commands and queries 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 perform queries for directly from the cache. The moment a command 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.

img/blog/client-side-architecture/Frame_46_(1).png

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.

img/blog/client-side-architecture/Frame_49.png

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.

img/blog/client-side-architecture/Frame_50.png

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.

Next page

III. Layers

Understanding the layers/concerns and their responsibilities in a client-side application.

Next page →

Discussion

Liked this? Sing it loud and proud 👨‍🎤.


0 Comments

Be the first to leave a comment

Submit

Stay in touch!



About the author

Khalil Stemmler,
Developer Advocate @ Apollo GraphQL ⚡

Khalil is a software developer, writer, and musician. He frequently publishes articles about Domain-Driven Design, software design and Advanced TypeScript & Node.js best practices for large-scale applications.



View more in Client-Side Architecture



You may also enjoy...

A few more related articles

Client-Side Architecture Basics [Guide]
Though the tools we use to build client-side web apps have changed substantially over the years, the fundamental principles behind...
How to Test Code Coupled to APIs or Databases
In the real-world, there's more to test than pure functions and React components. We have entire bodies of code that rely on datab...
How to Mock without Providing an Implementation in TypeScript
Having to provide an implementation everytime you create a test double leads to brittle tests. In this post, we learn how to creat...
When to Use Mocks: Use Case Tests
Mocking gets a pretty bad rap. However, if you're building an application using object-oriented programming and you're making use ...

Want to be notified when new content comes out?

Join 10000+ other developers learning about Domain-Driven Design and Enterprise Node.js.

I won't spam ya. 🖖 Unsubscribe anytime.

Get updates