Having conventions around code patterns makes a codebase easy to navigate and work with. You get it for free when you use opinionated frameworks like Rails, but the Shopify CLI doesn't have a framework, and therefore it's our responsibility to define and ensure that patterns are followed. What follows is the set of patterns that you'll find across the codebase and that we require contributors to follow.
Default exports force the module importer to decide on a name, which leads to inconsistencies and thus makes a codebase harder to navigate. Use named exports and make it explicit in the name of the domain the function belongs to. The following API from Node is a bad example because it's hard to know the meaning of join
far from the import
context:
import { join } from "node:path"
A better name for the above function would have beeen:
import { joinPath } from "node:path"
Modules must not perform any side effect when they are imported. For example, doing an IO operation at the root of the module:
// some-module.ts
import { fs } from "fs"
const content = fs.readSync("./package.json")
Modules with side effects might increase the load time of the dependency graph and complicate writing tests and reasoning about the code.
Don't use modules to store state at runtime. Due to how Node module resolution works, we don't have control over how package managers organize modules in the filesystem. Therefore, projects might end up with more than one copy of a module (and its state) in the dependency graph, which can lead to unexpected behaviors. For example:
// store.ts
const isInitialized = false;
Instead, you can:
- Store the state in the system. It leads to IO operations, which impact the performance, but because the state is often little, it's preferred over an unreliable experience.
- Load and pass the state down: Load the state upfront, for example, an in-memory representation of the project the CLI is interacting with, and pass it down through function arguments.
When designing the implementation of a function, refrain from mutating objects that the function receives as arguments. Function callers might design their business logic to assume that the arguments they pass to other functions are not mutated. If they do, the integration might not behave as expected, manifesting as bugs on the user side.
// Bad pattern
optimize(app)
// Better pattern
const optimizedApp = optimize(app)
Note that copies come with an overhead in memory consumption, but considering the size of the state, the CLI deals with, and optimizations Javascript engines usually include, it shouldn't be an issue.
This pattern is a map of the well-known model-view-controller to the domain of a CLI.
Commands are akin to views in MVC. They represent the interface users interact with. Unlike web or mobile apps, where a view has a graphic representation, commands represent users' intents and describe how users can invoke them. A command is represented by a name, description, and a set of flags and arguments that users can pass. Their responsibility is parsing and validating arguments and flags. Business logic must be delegated to services that represent units of business logic.
The commands' hierarchy is laid out inside the src/cli/commands
directory. Every subdirectory represents a level of commands, and the command's name matches the file name. Below there's an example of the file structure that we need for the shopify app build
command:
app/
src/
cli/
commands/
build.ts
Services represent reusable units of business logic. They export a default function representing the service and might contain additional internal combined functions to form the service. Each command must have a service representing it, and we might have additional services that don't map to commands. Note that services are decoupled from commands, so they do not know of flags and arguments. They usually take an options object that aligns with the command's flags.
// commands/serve.ts
import { devService } from "../services/dev"
export default class Dev extends Command {
static description = 'Dev the app'
async run(): Promise<void> {
const {args, flags} = await this.parse(Dev)
const directory = flags.path ? path.resolve(flags.path) : path.cwd()
const app = loadApp(directory)
await devService({app, port: flags.port})
}
}
Services live under the services
directory inside cli
. For services that represent a command's business logic, the name must match the name of the command represent.
app/
src/
cli/
services/
build.ts # For the build command
It is the application's dynamic data structure. They are represented by a class or a Typescript interface or type that a Javascript object can implement. If a model is scoped to a particular file, it can be defined at the top of the file. For example, some models are internal to services. However, suppose the model is core to the domain the package represents, for example, App. In that case, it must live in its own file that represents it.
❗ Models and business logic
It might feel tempting to add business logic to models. Refrain from doing it. A model is an object with whom the business logic operates. For example, if we have business logic for loading and validating an app, we'll have that function. The output will be the model ready to pass to other components down the process.
The model can contain convenient getters and setters to interact with the model.
Models live in the models
directory under cli
:
app/
src/
cli/
models/
app.ts
Some additional patterns have emerged over time and that don't fit any of the MCS groups:
- Prompts: Prompts are a particular type of service that prompts the user, collects, and returns the responses as a Javascript object. We recommend creating them under the
prompts/
directory. - Utilities: Utilities represent a sub-domain of responsibilities. For example, we can have a
server
utility that spins up an server to handle HTTP requests.
Public modules must live in the src/public
directory in any of the following sub-directories:
node
: For modules that are dependent on the Node runtime.browser
For the modules that are dependent on the browser runtime.common
: For modules that are runtime-agnostic.
The sub-organization helps clarify the runtime the functions exported by the module can run. For example, if we provide utilities for manipulating arrays, we'd create them in a src/public/common/array.ts
module, and the consumers of the @shopify/cli-kit
package would import them as:
import { getArrayHasDuplicates } from "@shopify/cli-kit/common/array"
Private modules must live in the src/private
directory using the same above subdirectories: node
, browser
, common
.
Most IDEs integrate the code documentation to enrich developers' experience writing code. Let's ensure we enable that experience for the developers using @shopify/cli-kit
and ensure the exported elements from the public modules are documented following the JSDoc standard.