A Fullstack Javascript Monorepo example

alecap7

Alessandro Capogna

Posted on August 26, 2020

A Fullstack Javascript Monorepo example

You are working on a large javascript project. It is full of features and growing by the day.
You have web and mobile clients on the frontend and services of any kind on the backend side.
Every part of your application is somehow coupled with other parts to work and even starting the project in the development phase is a pain ...

If that's the case, hear what I have to say about monorepos.

(source code)

What is a Monorepo?

As Wikipedia says:

A monorepo is a software development strategy
where code for many projects is stored in the same
repository.

Simple and straightforward.

Here is a typical javascript monorepo structure:

repo-root/
  package.json
  projects/
    project-1/
      package.json
    project-2/
      package.json
Enter fullscreen mode Exit fullscreen mode

Tools

When it comes with javascript we have at least two tools to work with to manage our projects in a single repository.

Yarn

Yarn is a well-known dependencies management tool (equivalent to npm). It aims to be also a project management tool by providing multi-package management primitives called workspaces:

Workspaces are a new way to set up your package architecture that’s
available by default starting from Yarn 1.0. It allows you to setup
multiple packages in such a way that you only need to run yarn
install
once to install all of them in a single pass.

Basically using these features we will have a single yarn.lock and a single node_modules folder at root level, which means all our project dependencies will be installed together so that yarn will be able to increase performances at installation time.

Furthermore it allows to define dependencies between internal packages with zero additional configurations.

Lerna

A tool for managing JavaScript projects with multiple packages.

Lerna offers utilities such as the ability to run custom scripts on a specific set of subprojects and integrated solutions for versioning and package publishing based on what has been changed in the repository (check my post on this topic).

For the sake of completeness, it offers all the features natively implemented by the yarn workspaces but also the possibility of integrating them: we will choose the second option.

For a more exhaustive discussion on yarn, lerna and monorepos I recommend this wonderful article.

The sample project

Our sample project is a toy application that fetches some books from the backend and displays them through a web interface.

However, to implement it I have chosen an architecture such that:

  • It is a microservices architecture, especially the frontend and the backend will be two separate applications.
  • It is also modular, therefore with the possibility of creating packages that can be shared by multiple applications.
  • Can be easily enhanced to cover at least one real world use case (this architecture is inspired by the Storybook Design System Architecture)

Folders structure

We are going to split our projects into two distinct folders: applications and packages.

The applications folder will contain all the components that make up our application at runtime, in our case a graphql api and a reactjs client.

The packages folder will contain modules shared by our applications, in our case a react components package (here called design-system).

enter image description here
The final folders structure will looks like:

repo-root/
  package.json
  packages/
    design-system/
      package.json
  applications/
    client/
      package.json
    api/
      package.json
Enter fullscreen mode Exit fullscreen mode

Yarn/Lerna setup

First you need to setup the management tools for the monorepo.

Inside the root:

yarn init
Enter fullscreen mode Exit fullscreen mode

Note: yarn workspaces require the root package.json to be private, so during the yarn initialization process make sure to set the private flag to true.

Then we have to install lerna:

yarn add lerna -D
yarn lerna init
Enter fullscreen mode Exit fullscreen mode

I always prefer to install this kind of dependencies as devDependencies.

Next we define yarn workspaces according to our project structure:

// package.json

{  
  
  "private": true,
  "workspaces": [
    "applications/*",
    "packages/*"
  ],
    
}
Enter fullscreen mode Exit fullscreen mode

Then we instruct lerna how to integrate itself with yarn workspaces:

// lerna.json

{
  ...
  "packages": [
    "applications/*",
    "packages/*"
  ],
  "npmClient": "yarn",
  "useWorkspaces": true,
  ...
}
Enter fullscreen mode Exit fullscreen mode

Finally we add a custom script for starting our apps during development:

// package.json

{  
  
  "scripts": {
    "start": "yarn lerna run development:start --parallel"
  },
    
}
Enter fullscreen mode Exit fullscreen mode

Coding the api application

For the backend I chose graphql. In particular we are going to implement the getting started tutorial of the official apollo website (with the addition of babel to take advantage of the javascript ES6 syntax).

First we have to make a new directory and cd to it:

mkdir -p applications/api
cd applications/api
Enter fullscreen mode Exit fullscreen mode

Then we have to initialize our project dependencies

yarn init -y
yarn workspace applications/api add @babel/core @babel/cli @babel/node @babel/preset-env nodemon -D
yarn add apollo-server graphql
yarn install
Enter fullscreen mode Exit fullscreen mode

and his files and folders

mkdir src
touch src/index.js
touch .babelrc
Enter fullscreen mode Exit fullscreen mode

Next we have to add some configurations.

Here we define a script to start our graphql app:

// applications/api/package.json

{
  ...
  "scripts": {
    ...
    "development:start": "yarn nodemon --exec babel-node src/index.js ",
    ...
  },
  ...
}

Enter fullscreen mode Exit fullscreen mode

Here we define presets for our Babel compiler:

// applications/api/.babelrc

{
  "presets": ["@babel/preset-env"]
}
Enter fullscreen mode Exit fullscreen mode

Finally we can add the code:

// applications/api/src/index.js

import { ApolloServer, gql } from "apollo-server";

const typeDefs = gql`
  type Book {
    title: String
    author: String
  }

  type Query {
    books: [Book]
  }
`;

const books = [
  {
    title: "Harry Potter and the Chamber of Secrets",
    author: "J.K. Rowling"
  },
  {
    title: "Jurassic Park",
    author: "Michael Crichton"
  }
];

const resolvers = {
  Query: {
    books: () => books
  }
};

const server = new ApolloServer({ typeDefs, resolvers });

server.listen().then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});
Enter fullscreen mode Exit fullscreen mode

You can now test it by running:

yarn development:start
Enter fullscreen mode Exit fullscreen mode

or

cd ../..
yarn start
Enter fullscreen mode Exit fullscreen mode

Coding the client application

For the client side we are going to build a react web app with an apollo client to work with the graphql backend.

First we bootstrap a new cra project:

npx create-react-app applications/client
Enter fullscreen mode Exit fullscreen mode

Remember we want only one yarn.lock and it has to be placed on the root level, so make sure cra hasn't created a yarn.lock. Otherwise:

rm applications/client/yarn.lock
Enter fullscreen mode Exit fullscreen mode

Next we install dependencies:

cd applications/client
yarn add @apollo/client graphql
Enter fullscreen mode Exit fullscreen mode

Then we add some configurations:

// applications/client/package.json

{
  ...
  "scripts": {
    "development:start": "CI=true yarn react-scripts start",
    ...
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Finally, we add the code:

// applications/client/src/App.js

import React from "react";  
import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";  
import Books from "./components/Books";

const client = new ApolloClient({  
  uri: "http://localhost:4000",  
  cache: new InMemoryCache()  
});

function App() {  
  return (  
    <ApolloProvider client={client}>  
      <Books />  
    </ApolloProvider>  
  );  
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Here we are creating the content of our app:

mkdir src/components
touch src/components/Books.js
Enter fullscreen mode Exit fullscreen mode
// applications/client/src/components/Books.js

import React from "react";
import { useQuery, gql } from "@apollo/client";

const ALL_BOOKS = gql`
  query GetAllBooks {
    books {
      title
      author
    }
  }
`;

function Books() {
  const { loading, error, data } = useQuery(ALL_BOOKS);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error :(</p>;

  return data.books.map(({ title, author }) => (
    <div key={title}>
      <p>
        {title} by {author}
      </p>
    </div>
  ));
}

export default Books;
Enter fullscreen mode Exit fullscreen mode

Test it by running:

cd ../..
yarn start
Enter fullscreen mode Exit fullscreen mode

Note how this will start also the api application.

Coding the design-system package

Here we are going to package some react components.

First we have to make a new directory and cd to it:

mkdir -p packages/design-system
cd packages/design-system
Enter fullscreen mode Exit fullscreen mode

Then we have to init our project and his structure:

yarn init -y
yarn add react@^16.0.0 -P  
yarn add microbundle-crl -D
Enter fullscreen mode Exit fullscreen mode
mkdir src
touch src/index.js
mkdir src/components
touch src/components/List.js
touch src/components/ListItem.js
Enter fullscreen mode Exit fullscreen mode

Next we add some configurations:

// packages/design-system/package.json

{
  ...
  "main": "dist/index.js",
  "module": "dist/index.modern.js",
  "source": "src/index.js",
  "scripts": {
    ...
    "development:start": "yarn microbundle-crl watch --no-compress --format modern,cjs"
    ...
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

Finally, we add the code:

// packages/design-system/src/index.js

import List from "./components/List";

export { List };
Enter fullscreen mode Exit fullscreen mode
// packages/design-system/src/components/ListItem.js

import React from "react";
import PropTypes from "prop-types";

// I'm not using css files because they will not work when exported!
// Consider to use styled components for your project...
function ListItem(props) {
  return (
    <div
      style={{
        margin: "10px",
        padding: "10px",
        border: "1px solid #bbb",
        backgroundColor: "#eee"
      }}
    >
      <span
        style={{
          fontSize: "1.2em",
          textDecoration: "none",
          color: "#333"
        }}
      >
        {props.text}
      </span>
    </div>
  );
}

ListItem.propTypes = {
  text: PropTypes.string.isRequired
};

export default ListItem;
Enter fullscreen mode Exit fullscreen mode
// packages/design-system/src/components/List.js

import React from "react";  
import PropTypes from "prop-types";  
import ListItem from "./ListItem";

function List(props) {  
  return (  
    <div>  
      {props.items.map((content, index) => (  
        <ListItem key={index} text={content || ""} />  
      ))}  
    </div>  
  );  
}

List.propTypes = {  
  items: PropTypes.arrayOf(PropTypes.string).isRequired  
};

export default List;
Enter fullscreen mode Exit fullscreen mode

As last step we have to update the client app:

// applications/client/src/components/Books.js

import React from "react";
import { useQuery, gql } from "@apollo/client";
import { List } from "design-system";

const ALL_BOOKS = gql`
  query GetAllBooks {
    books {
      title
      author
    }
 }
`;

function Books() {
  const { loading, error, data } = useQuery(ALL_BOOKS);
  if (loading) return <p>Loading</p>;
  if (error) return <p>Error :(</p>;
  return (
    <List
      items={data.books.map(({ title, author }) => `${title} by ${author}`)}
    />
  );
}

export default Books;
Enter fullscreen mode Exit fullscreen mode

And its dependencies:

yarn add design-system@^1.0.0
Enter fullscreen mode Exit fullscreen mode

You can now test the final app:

cd ../..
yarn start
Enter fullscreen mode Exit fullscreen mode

Note: currently there seems to be a bug with react's development server. After the first start, the page must be refreshed.

Space for improvements

Our app is so simple that such a complex architecture might seem totally unjustified.

However, think this way... You want this book listing app to become the best online bookstore in the world!

On the client side, you'll need at least a store app for your customers and a dashboard for you suppliers.

On the server side the underneath data model will explode. You will have to manage your users, track orders and so on. That is, you will have to write tons of business logic line of codes and probably integrations to 3rd party systems. To preserve principles of low-coupling and high-cohesion through your code you'll need to split these logics across many applications and modules.

Your app will probably looks more like this:

enter image description here

According to the proposed monorepo structure it is easy to scale up the project while keeping your code manageable. You will simply create all the new packages and / or applications you need under the appropriate folders.

Conclusions

The disruptive rise of javascript in the field of web development has reached a state of the art in which it is possible to develop very complex applications in a single programming language.
This situation offers some advantages such as the possibility of centralizing project management partially described here.

I sincerely hope that my thoughts on this issue will be of help to your current or next project.

Any sort of feedback is very appreciated!

💖 💪 🙅 🚩
alecap7
Alessandro Capogna

Posted on August 26, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related