Skip to main content

Convention and Best Practices

We're using monorepo and micro packages approach. This means instead of single package.json for our entire app, we have multiple smaller packages with specific functionality.

We also separate our services into different packages as well. This is made possible because we use pnpm workspaces. With pnpm workspaces, instead of importing via relative path or hacking absolute path, we use real dependency like we use npm packages.

// Instead of
import isAuthorized from '../../../../user/isAuthorized';
// or
import isAuthorized from 'src/user/isAuthorized';

// We do this
import { isAuthorized } from '@traveloka/user';

Micro packages

Every packages in our monorepo exist inside packages/ directory. The convention is using the same package name with directory name prefixed by @traveloka/. For example we have core directory with @traveloka/core as its package name.

For other product specific package, we have convention where you'll have at least 3 micro packages: 1 for shared modules, and the last 2 are for both desktop and mobile services. For example user product will have user/common, user/app-desktop and user/app-mobile. We'll call those shared package and service packages.

+ packages/
+- user/
|- common/
|- app-desktop/
+- app-mobile/

Product package should only contains reusable modules that is used in all interfaces. If you want to create module specific to some interface (desktop / mobile), instead of putting them in your shared packages, simply place it in your service packages.

Both app-desktop and app-mobile should not become dependency for other modules because these are service package that will be deployed to production. If you need shared interface-specific modules, you can use desktop and mobile directory prefix in your common package

+ packages/
+- user/
+- common/
+- desktop/
+- index.ts
+- mobile/
+- index.ts
+- index.ts

Import from root

If you notice import example above, you see that preferred approach is using named import instead of traversing through directory. Instead of importing via path, expose your modules at root and import them using named import.

Your main file in the shared package should be similar to this:

export { namedImport1, namedImport2 } from './path';
export { default as namedImport3 } from './another';

Using named import on root have many benefits, primarily because importing via path is supposed to be implementation detail and undocumented API. When you import only at root, you can move around files in your shared package without having to worry it will break other packages consuming your modules.

Directory structure

Importing from root technically means you can use your own prefered directory structure, but it's better to follow the convention. In your shared package, group modules not by what it does but by features that the module is used. Try not creating directories like reducers, containers at the top level that contains modules that span different features.

You can think of features as page variation that you have in your product. For example user shared packages might contain login, register, profile, and other directories. We call these feature directories.

+ packages
|- user/
+- common
| |- login/
| |- register/
| +- profile/
|- app-desktop/
+- app-mobile/

Inside feature directory, there's no hard rule that you need to follow, you're free to put it wherever you want. Generally it's best to keep hierarchy to minimum, but you can discuss with your colleague what your prefered approach are on organizing files. Similarly, services package also have the same convention in term of grouping modules.

Code style guide

Explaining and deciding on code styleguide is harder than everyone thinks, so instead of arguing, we just enforce it through automated tools. We're using Prettier for code style and Eslint for our JS linter.

Prettier

Prettier runs on pre-commit stage, this is to make sure that you're never forget to format your code before commiting files. You can opt in for automated code formatting in your editor as well.

Eslint

Eslint on the other hand, runs in CI alongside with our typecheck. This is because linting entire repo could take a long time, and we don't want to waste your time everytime you want to commit some files.

Similar with prettier, you can integrate linter with your editor to get faster feedback loop.