Skip to main content

Create your first Module

This tutorial takes a new instance and adds one small module of your own to it: a store notice. The shop owner types a short message in the admin ("We are closed 24-26 December"), and the storefront — or anything else — reads it from an API route. It takes about 20 minutes.

The page describes an instance created with npx create-endora-commerce@latest on the release after 0.100.2.

An instance created with release 0.100.2 or earlier

Four things on this page behave differently there. Instances created with 0.100.2 or earlier lists them, each with its repair.

What a module is​

A module is one self-contained capability of the shop — the blog, the product comparison, the newsletter. It declares what it is in a manifest (its name, its settings, its permissions, what it depends on) and registers what it does in one function, registerModule (its services, its API routes). The platform composes every module the same way, so a module can be switched off and behaves as if it were never installed.

The modules that came with your instance are packages in node_modules, and you never edit them. A module of your own lives in your instance's apps/<deployment>/modules/ directory and is called an overlay module: it is added on top of the platform, and no platform file changes.

What you need​

  • An instance from Getting started. This page assumes it is in my-shop, created with the default deployment name, so your own modules go in apps/my-shop/modules/. If you passed --deployment, or named the directory differently, use the directory you find under apps/.
  • Its development services running (pnpm run dev:services), and the administrator you created during the install.
  • A terminal in the instance's root directory. Stop pnpm run dev:all if it is running (Ctrl-C); you start it again in step 7.

Step 1 — Check the instance knows its deployment​

The platform composes your overlay modules only when it knows which deployment it is running as, and a manifest is written with two helpers from the @endora-commerce/contracts package. The installer sets both up: .env in the instance's root has a DEPLOYMENT line naming the directory under apps/, and package.json lists @endora-commerce/contracts.

grep DEPLOYMENT .env
grep '@endora-commerce/contracts' package.json
DEPLOYMENT=my-shop
"@endora-commerce/contracts": "0.100.2",

The version is the one your release pins; like every package of the release, it is written without a ^ on purpose.

Without DEPLOYMENT nothing fails: the instance starts as the bare platform, your module is simply not there, and nothing says why. If either command prints nothing, your instance was created by an earlier release — see Instances created with 0.100.2 or earlier.

Step 2 — Scaffold the module​

One command writes the module's directory, with a manifest, an entry point and both translation files. The first argument is the module's id — lower-case letters, digits and underscores — and it becomes the directory name.

pnpm exec endora new module store_notice \
--name "Store notice" \
--description "A short notice the shop owner writes and the storefront can display." \
--permission "store_notice:read=View the store notice"
endora new module store_notice — an overlay module in apps/my-shop/modules/store_notice.
wrote manifest.ts
wrote backend.ts
wrote i18n/en.json
wrote i18n/pl.json

What it wrote already works: a module with an on/off switch, a public route and an admin route guarded by the permission. The next three steps turn it into the store notice. Run the command with --dry-run to read the files without writing them.

Step 3 — Write the manifest​

Replace the scaffolded manifest with this one. It adds a second Setting, for the notice text.

apps/my-shop/modules/store_notice/manifest.ts
import { defineModuleManifest, defineModuleSettingsManifest } from '@endora-commerce/contracts';

const settings = defineModuleSettingsManifest({
moduleCode: 'store_notice',
groups: [{ code: 'store_notice', name: 'Store notice' }],
settings: [
{
code: 'store_notice.enabled',
name: 'Store notice enabled',
description:
'Switches the store notice module on or off as a whole. Nothing is deleted: the notice text is kept and comes back when you switch the module on again.',
groupCode: 'store_notice',
valueType: 'boolean',
defaultValue: true,
},
{
code: 'store_notice.message',
name: 'Notice text',
description:
'A short message for your customers, for example a holiday closure. Leave it empty to show nothing.',
groupCode: 'store_notice',
valueType: 'string',
defaultValue: '',
},
],
});

export const manifest = defineModuleManifest({
id: 'store_notice',
name: 'Store notice',
description: 'A short notice the shop owner writes and the storefront can display.',
version: '1.0.0',
dependencies: ['settings', 'auth'],
settings,
i18n: { bundlesDir: 'i18n' },
permissions: [{ code: 'store_notice:read', label: 'View the store notice' }],
activation: { settingCode: 'store_notice.enabled', default: true },
});

What each part is for:

  • settings — two Settings the module owns. store_notice.message is the module's data: the admin gets a field for it with no screen written by you. store_notice.enabled is the module's on/off switch, which activation points at.
  • dependencies — the modules this one needs. settings stores the notice; auth owns the check that an administrator is signed in, used in step 4.
  • permissions — the permission code that guards the admin route. Declaring it here is what makes it grantable to a role.
  • i18n — the directory, beside the manifest, holding the module's translations.

Step 4 — Register the routes​

Replace the scaffolded backend.ts with this one, which reads the notice from the Setting:

apps/my-shop/modules/store_notice/backend.ts
import { z } from 'zod';
import { lazyPort } from '@endora-commerce/platform/kernel';
import type {
ModuleContext,
RequireAdminFactory,
SettingsReadPort,
} from '@endora-commerce/platform/kernel';

interface StoreNoticeCradle {
readonly requireAdmin: RequireAdminFactory;
}

export function registerModule(ctx: ModuleContext): void {
const settings = lazyPort<SettingsReadPort>(ctx, 'settingsReadPort');
const readNotice = async (): Promise<{ message: string; visible: boolean }> => {
const message = (await settings.get('store_notice.message', null, z.string())).trim();
return { message, visible: message.length > 0 };
};

ctx.routes(async (app) => {
app.get('/api/v1/store-notice', async () => ({ data: await readNotice() }));

app.get(
'/api/v1/admin/store-notice',
{ preHandler: ctx.cradle<StoreNoticeCradle>().requireAdmin('store_notice:read') },
async () => ({ data: await readNotice() }),
);
});
}
  • registerModule(ctx) is the module's one entry point. The platform calls it once at start.
  • ctx.routes(…) registers the routes, and is why the on/off switch works: routes registered through it answer 503 while the module is off. app is an ordinary Fastify instance.
  • lazyPort(ctx, 'settingsReadPort') is how a module reads something another part of the platform provides. null as the second argument reads the platform-wide value rather than one sales channel's.
  • requireAdmin('store_notice:read') guards the second route with the permission from the manifest. The code must match exactly.

Nothing in an instance compiles this file: Node runs it directly and removes the types as it goes. So write plain TypeScript — an enum, a namespace or a constructor parameter property stops the start with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.

Step 5 — Add the translations​

Every user-facing string ships in English and Polish. This module has one: the label of its permission, shown when a role is edited. The files are flat key: text maps. The scaffold wrote both with your English text, so the English file is already right and the Polish one needs its translation.

apps/my-shop/modules/store_notice/i18n/en.json
{
"adminRoles.permission.store_notice:read": "View the store notice"
}
apps/my-shop/modules/store_notice/i18n/pl.json
{
"adminRoles.permission.store_notice:read": "Odczyt komunikatu sklepu"
}

Step 6 — Install the module​

The platform now sees the module, and reports it as not installed:

pnpm run module:status
store_notice not-installed — 1.0.0 settings,auth

Install it. This is the step that creates its settings and loads its translations:

pnpm run module:install store_notice
[install] store_notice 1.0.0
✓ dependencies satisfied
✓ migrations applied: (none)
✓ settings reconciled: +1 groups, +2 settings (~0 updated)
✓ install hook completed (0 ms)
✓ registry updated: state=installed

Then record what your deployment now does differently from the platform:

pnpm run generate

The command stops with an error, and among its output is one line about your module:

[undeclared-divergence] apps/my-shop/divergence.ts: no reason for `port-consumed:store_notice:settingsReadPort` — 'store_notice' resolves the port 'settingsReadPort', owned by a composition root

The instance keeps a report of every place it reaches into the platform, and asks for one sentence of your own per entry. The report is written either way; the command fails until every entry has its sentence, so an unexplained change cannot slip through pnpm run setup unnoticed. Open apps/my-shop/divergence.ts and give the reason, under the key the message names:

apps/my-shop/divergence.ts
export const divergence = {
omittedModules: [],
decorationOrder: {},
reasons: {
'port-consumed:store_notice:settingsReadPort':
'The store notice text is a Setting, so the module reads it through the settings port.',
},
} as const;

Run pnpm run generate again: it succeeds, the line is gone, and apps/my-shop/divergence.generated.md lists store_notice with your sentence. That file is the first thing to read when a platform upgrade changes something you relied on.

Step 7 — See it work​

Start the instance:

pnpm run dev:all

The addresses below are the defaults. If the installer moved a port because it was taken, use the address it printed — the API's port is PORT in the instance's .env, the admin's is PORT in admin/.env.

The API route. In a second terminal:

curl http://localhost:3001/api/v1/store-notice
{"data":{"message":"","visible":false}}

The setting, in the admin. Open the admin at http://localhost:3002, sign in, and go to System → Settings. Find the Store notice group, type a message into Notice text and press Save 1 change(s). Ask the route again:

{"data":{"message":"We are closed 24-26 December. Orders ship from 27 December.","visible":true}}

The permission and its translation. Go to System → Roles and pick a role. The permission list has a STORE_NOTICE group with store_notice:read — View the store notice. Switch the admin to Polish with the language picker in the top bar and the same line reads Odczyt komunikatu sklepu.

The admin route GET /api/v1/admin/store-notice answers 401 without a signed-in administrator and the same body as the public route with one; step 8 shows how to call an admin route from a terminal.

Step 8 — Switch it off and on​

Every module can be switched off by the people running the shop, and a module that is off behaves as if it were not installed. Nothing is deleted, and switching it on again brings everything back.

In the admin. Open System → Modules (/platform/modules). Every module of the instance is listed there, yours included, with what the deployment installed and what your business has switched on. Switch Store notice off with the control in its row.

From a terminal. The call below is the one the Modules screen itself makes. Sign in, keeping the session cookie in a file:

curl -c cookies.txt -H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"your-password"}' \
http://localhost:3001/api/v1/auth/admin/login

Switch the module off:

curl -b cookies.txt -H 'content-type: application/json' \
-d '{"active":false}' \
http://localhost:3001/api/v1/admin/modules/store_notice/activation
{"module":{"id":"store_notice","present":false,"platformState":"installed","activated":false,"deactivatable":true,"nonDeactivatableReason":null}}

Now the route refuses, with no restart:

curl -i http://localhost:3001/api/v1/store-notice
HTTP/1.1 503 Service Unavailable

{"error":{"code":"MODULE_DISABLED","message":"The \"store_notice\" module is off, so this action was refused. Check its state on the Modules screen.", …}}

The store_notice:read permission has also left the list of permissions a role can be given. Switch the module on again with the same call and {"active":true}: the route answers 200, with the notice you saved. Delete cookies.txt when you are done.

You wrote no code for any of this. It follows from two things you already did: the activation line in the manifest, and registering the routes through ctx.routes.

There is a second switch, and it belongs to whoever runs the deployment rather than to the shop owner: pnpm run module:disable store_notice and pnpm run module:enable store_notice. It is recorded in the module registry rather than in a Setting, a running API hears it without a restart, and pnpm run module:status shows it. A module is present only when both say so — Module lifecycle has the rest, including module:uninstall.

What an overlay module cannot be​

Three limits decide what your first module can be:

  • An overlay module cannot own a database table. It contributes settings, routes, permissions and translations, and no entity and no migration — nothing in an instance would run them, so no table would be created. A migrations/ or entities/ directory inside an overlay module is refused, naming each file, by pnpm run generate, by pnpm run migrate, by every module:* command and by the API and the worker when they start. Keep small state in Settings, as this module does.
  • A module that needs its own table is a module package, and so is a module with its own admin screen. Inside an instance endora new module writes an overlay module, and refuses --entities and --admin with the reason. It scaffolds a package — with --entities for a table and --admin for a screen — only inside a checkout of the Endora Commerce repository. This tutorial does not cover writing a package by hand.
  • This tutorial does not show the notice in the storefront. The storefront the installer wrote beside your instance is yours to edit — see The storefront — and the route is what it would call.

Instances created with 0.100.2 or earlier​

An instance written by release 0.100.1 or 0.100.2 differs from this page in four ways. The first two are files the installer did not write, and you repair them by hand; the last two are repaired in the platform packages, so they go away when you upgrade the instance to a later release — see Upgrading an instance.

WhatWhat you seeWhat to do
.env has no DEPLOYMENT lineYour module is not composed, and nothing says whyecho "DEPLOYMENT=my-shop" >> .env
package.json does not list @endora-commerce/contractsThe manifest's import does not resolveThe pnpm add command below
pnpm exec endora new module refuses inside an instanceIt asks for a checkout of the repositoryUpgrade, or write the four files of steps 3 to 5 by hand
The Modules screen does not load, and module:disable, module:enable and module:uninstall change nothingNOT_FOUND: Resource not found. and "No modules to show"; the command prints state=disabled while pnpm run module:status still says installedUpgrade, or use the API call in step 8, which works on every release
pnpm add -w "@endora-commerce/contracts@$(node -p "require('./node_modules/@endora-commerce/platform/package.json').dependencies['@endora-commerce/contracts']")"

The part in $(…) prints the exact version of the contracts package your installed platform uses, so the two stay on one release.

On those releases pnpm run generate also exits 0 with an unexplained entry in the divergence report, and it does not refuse a migrations/ directory in an overlay module: read its output rather than its exit code.

Where to go next​

  • Overlay pattern — everything an overlay module may do, including changing what a platform service does with ctx.di.decorate.
  • The customisation ladder — which seam to reach for first, and what each one costs you at the next upgrade.
  • Permissions — how permission codes, roles and modules relate.
  • Module lifecycle — installing, enabling, disabling and uninstalling.
  • Admin UI translations — the Polish glossary and how a module's i18n/ bundles are loaded.
  • Modules — the modules your instance already has; their pages show what a finished module declares.