JavaScript nowadays is almost everywhere: on the backend, frontend, desktop, mobile, tooling etc.
If your project consists of multiple JavaScript repositories, now, it’s much better to move them into a single/mono repository and control them using Lerna.
What is Lerna?
Note: Lerna is a tool for managing JavaScript projects with multiple packages.
I recommend you take a look at lerna commands before we proceed.
Why Monorepo?
Monorepo is NOT about mixing everything together inside a project(do not confuse with monolith).
Monorepo is about having source codes of multiple applications, services, and libraries that belong to one domain inside a single repository.
Note: Monorepo can be organized in any comfortable way, directory tree, it’s up to developer/team.
Pros:
- Fixed versioning for the whole system (apps/services/libraries).
- Cross-project changes. For instance, a feature that changes multiple parts of the system (libraries/services/apps) can be done in one Pull Requests.
- Single clone. No need to clone several repositories.
- Access to all parts of the system. Simplified refactoring of the whole system.
Cons:
- Version control system performance downside.
- Security. Access to all parts of the system by all developers.
Requirements
- Node.JS version 8 or above
Getting Started
For the purpose of this example, we will create a simple app consisting of:
- API: API service (backend)
- frontend: frontend/web app
Also, in order not to mix all logic together, we’ll create separate packages:
- validator: custom validation library/package
- logger: custom logging library/package
The overall file structure of our monorepo project would be:
packages/ # directory for our custom libraries
../validator # custom validation helpers
../logger # custom logger library
apps/ # directory for our apps/services
../api # API backend
../frontend # frontend/web
1. Initialize Monorepo
# install lerna globally
npm i -g lerna
# create a new dir for project
mkdir my-project && cd my-project
# initialize a new lerna managed repository
lerna init
and Edit lerna.json
{
"packages": ["packages/*", "apps/*"],
"version": "0.1.0"
}
Note: We’ll use npm scoped package naming for all our apps and packages. Example:
@my-project/{package-name}
Let’s start with library packages.
2. Create a “validator” library package
- Create and initialize
@my-project/validator
package:
# create library directory and cd inside
mkdir -p packages/validator && cd packages/validator
# initialize library with scope name
npm init --scope=my-project --yes
- Add
packages/validator/index.js
with the following content:
/**
* Checks if given value is null or undefined or whitespace string
* @param {string?} value
*/
exports.isNullOrWhitespace = (value) =>
value === undefined || value === null || !value.trim()
3. Create a “logger” library package
-
Create and initialize
@my-project/logger
package:# From the root directory of the repository # create library directory and cd inside mkdir -p packages/logger && cd packages/logger # initialize library with scope name npm init --scope=my-project --yes
-
Add
packages/logger/index.js
with the following content:const CYAN = "\x1b[36m" const RED = "\x1b[31m" const YELLOW = "\x1b[33m" const log = (color, ...args) => console.log(color + "%s", ...args) exports.info = (...args) => log(CYAN, ...args) exports.warn = (...args) => log(YELLOW, ...args) exports.error = (...args) => log(RED, ...args)
4. Create an “api” application package
-
Create and initialize
@my-project/api
package:# From the root directory of the repository # create app directory and cd inside mkdir -p apps/api && cd apps/api # initialize app with scope name npm init --scope=my-project --yes # install express npm i express --save # add our logger library as dependency to our api app lerna add @my-project/logger --scope=@my-project/api
-
Add
apps/api/index.js
file:const express = require("express") const logger = require("@my-project/logger") const PORT = process.env.PORT || 8080 const app = express() app.get("/greeting", (req, res) => { logger.info("/greeting was called") res.send({ message: `Hello, ${req.query.name || "World"}!`, }) }) app.listen(PORT, () => console.log(`Example app listening on port ${PORT}!`))
-
Add start script to
apps/api/package.json
:
"scripts": {
"start": "node index.js"
// ...
}
- Run app:
npm start
and open http://localhost:8080/greeting
5. Create a “frontend” application package
-
Create
@my-project/frontend
using create-react-app:# From the root directory of the repository # create frontend app using create-react-app cd apps && npx create-react-app frontend
-
Edit
apps/frontend/package.json
:{ "name": "@my-project/frontend", // ... "proxy": "http://localhost:8080" }
-
Add our validator as a dependency to our frontend.
# Add validator library as a dependency to frontend lerna add @my-project/validator --scope=@my-project/frontend
-
Add
apps/frontend/src/Greeting.js
:import React, { Component } from "react" import { isNullOrWhitespace } from "@my-project/validator" export class Greeting extends Component { state = { name: "", } onSubmit = () => { const { name } = this.state if (isNullOrWhitespace(name)) { alert("Please, type your name first.") return } fetch(`/greeting?name=${name}`) .then((response) => response.json()) .then(({ message }) => this.setState({ message, error: null })) .catch((error) => this.setState({ error })) } render() { const { name, message, error } = this.state return ( <div style={{ padding: "10px" }}> {message && <div style={{ fontSize: "50px" }}>{message}</div>} <input value={name} onChange={(event) => this.setState({ name: event.target.value })} placeholder="Type your name" /> <button onClick={this.onSubmit}>Submit</button> {error && <pre>{JSON.stringify(error)}</pre>} </div> ) } }
-
Add
<Greeting />
somewhere insideapps/frontend/src/App.js
:// ... import { Greeting } from "./Greeting" class App extends Component { // ... render() { return ( <div className="App"> <header className="App-header"> {/* ... */} <Greeting /> </header> </div> ) } }
-
Run frontend app:
npm start
and open http://localhost:3000
Conclusion
As you can see, a mono repository can contain as many apps, libraries as needed for the project. The important thing is to keep everything loosely coupled:
- One app/service/library → One package
Common logic, utils or helpers can be placed in a separate package. Thus, we can build highly independent packages, which is much simpler to understand, maintain and refactor.
See full source code on Github.
Also, you can look at the more advanced monorepo example here.