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.
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 inapps/my-shop/modules/. If you passed--deployment, or named the directory differently, use the directory you find underapps/. - 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:allif 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.
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.messageis the module's data: the admin gets a field for it with no screen written by you.store_notice.enabledis the module's on/off switch, whichactivationpoints at.dependencies— the modules this one needs.settingsstores the notice;authowns 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:
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 answer503while the module is off.appis an ordinary Fastify instance.lazyPort(ctx, 'settingsReadPort')is how a module reads something another part of the platform provides.nullas 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.
{
"adminRoles.permission.store_notice:read": "View the store notice"
}
{
"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:
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/orentities/directory inside an overlay module is refused, naming each file, bypnpm run generate, bypnpm run migrate, by everymodule:*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 modulewrites an overlay module, and refuses--entitiesand--adminwith the reason. It scaffolds a package — with--entitiesfor a table and--adminfor 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.
| What | What you see | What to do |
|---|---|---|
.env has no DEPLOYMENT line | Your module is not composed, and nothing says why | echo "DEPLOYMENT=my-shop" >> .env |
package.json does not list @endora-commerce/contracts | The manifest's import does not resolve | The pnpm add command below |
pnpm exec endora new module refuses inside an instance | It asks for a checkout of the repository | Upgrade, 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 nothing | NOT_FOUND: Resource not found. and "No modules to show"; the command prints state=disabled while pnpm run module:status still says installed | Upgrade, 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.