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
serverutility 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.browserFor 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.