Architecture
How we structured this repository using monorepos.
Monorepo Structure
This repository is organized as a monorepo, which includes various applications and packages that are developed, built, and published independently. We use Turborepo to manage our build system, which provides a high-performance environment for handling tasks across our JavaScript and TypeScript codebases.
Note: The root directory does not contain a
src/directory. Instead, individual projects—whether applications or packages—each have their ownsrc/directory withinapps/orpackages/.
Each project has its own package.json file. We utilize
pnpm Workspaces to manage dependencies efficiently
across all packages and applications.
Managing Dependencies in a Monorepo
Maintaining consistency across packages is essential to avoid subtle bugs and discrepancies. For example, mismatched TypeScript type definitions can cause hard-to-trace errors.
Our strategy is to create a consistent, stable, and predictable development environment for all packages in the monorepo. This is achieved by using explicit versioning for type declarations and crucial dependencies such as React.
Levels of package.json
-
Root Level
package.json:- Includes dependencies shared across multiple packages.
- Houses essential development tools like Vite, Vitest, and @vitejs/plugin-react-swc required by several packages.
-
Package Level
package.json:- Contains dependencies specific to that package.
- May include dependencies from the root
package.jsonbut with different versions or those unique to the package. - Uses the
workspace:protocol to reference other packages within the monorepo. - Consumes development dependencies from the root without separate installations.
Dependency Types
- dependencies: Required for the project to run, installed automatically with your package.
- devDependencies: Needed only for development, not installed with your package.
- peerDependencies: Indicates that your package can work only if the consumer also installs a specific version of this dependency.
Example of Dependency Specification
{
"name": "@akinon/some-package",
"version": "1.0.0",
"dependencies": {
"antd": "^5.0.0"
},
"peerDependencies": {
"react": ">=18 || >=19",
"react-dom": ">=18 || >=19"
}
}Handling Specific Version Requirements
If a package needs a different version from the monorepo standard, it’s specified in its own package.json. This allows for flexibility and isolated version management when needed.
Caching with Turborepo
Turborepo includes a robust task caching mechanism to accelerate the build and test processes. It stores results and input hashes, reusing them if the inputs haven’t changed, which saves time.
Public vs. Private Packages
In our monorepo, you can manage both public and private packages. Public
packages are published to npm, while private packages, marked with
"private": true in their package.json, are not.
Referencing Packages
To reference a package within the monorepo, use its name rather than a relative path, ensuring correct version management:
{
"name": "@akinon/docs",
"dependencies": {
"@akinon/app-client": "workspace:*",
"@akinon/icons": "workspace:*",
"@akinon/ui-react": "workspace:*",
...
}
}Using workspace:* ensures the latest version within the workspace is used.
Specify a version number to pin a specific version.
Creating a New Package
To start a new package within our monorepo:
- Choose apps/ for applications or packages/ for libraries.
- Create a folder with a descriptive name reflecting its functionality.
- Run npm init to generate a package.json file.
- Add any internal dependencies using the workspace: protocol.
- Install external dependencies as required.
- Begin development in the src/ directory.
Naming Conventions
- Scope: All packages are scoped under
@akinon. - Descriptive Names: Ensure names clearly reflect the package’s purpose.
- Kebab-Case: Use kebab-case for package names, e.g.,
@akinon/ui-components.
Using Turborepo
Turborepo is integral to our setup, functioning as a task runner with features like efficient caching and parallel task execution. It understands package dependencies, ensuring tasks run in the proper sequence.
turbo.json Configuration
Here’s a look at our turbo.json configuration:
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["NODE_ENV"]
},
"typecheck": {},
"lint": {
"outputs": []
},
"dev": {
"cache": false
},
"start": {
"cache": false
}
}
}- Pipeline: Defines the various tasks (e.g., build, test, lint) and their dependencies.
- Depends On: Specifies the dependencies of a task. The ^ symbol indicates that the task depends on the build task of all its dependencies.
- Outputs: Lists the file/directory patterns that should be cached after the task is executed.