Project Structure
Every Platform-Kernel module is a self-contained JavaScript/TypeScript
package that is deployed as a Module Federation 2.0 remote. The scaffold
generated by npx @platform/create-module produces the canonical structure.
Deviation from this structure is permitted, but the files listed as required
must exist exactly as specified.
Full Directory Tree
my-module/
├── module.manifest.json ← [REQUIRED] Module contract — read by the kernel
├── federation.config.js ← [REQUIRED] Module Federation 2.0 remote config
├── package.json ← [REQUIRED] npm package descriptor
├── tsconfig.json ← TypeScript configuration
│
├── src/
│ ├── pages/
│ │ └── index.tsx ← [REQUIRED] Main React entry component (exposed via MF)
│ ├── engines/ ← Business logic (pure TS, no React)
│ │ └── contacts.ts
│ ├── api/ ← Custom API client helpers (thin wrappers over SDK)
│ │ └── contacts.api.ts
│ ├── events/ ← Event handlers (subscribes declared in manifest)
│ │ └── onUserCreated.ts
│ └── components/ ← Module-private React components
│ └── ContactCard.tsx
│
├── migrations/ ← [REQUIRED if dataApi used] SQL goose migrations
│ └── 001_create_contacts.sql
│
└── tests/
├── unit/ ← Vitest unit tests (sdk-testing mocks)
│ └── contacts.engine.test.ts
└── integration/ ← Optional: Testcontainers-based integration tests
└── contacts.integration.test.ts
Root-Level Files
module.manifest.json — The Module Contract
The manifest is the single most important file in your module. The kernel reads it at registration time to configure routing, permissions, event subscriptions, data models, and billing limits. Nothing your module does at runtime is permitted unless it is declared here.
See Module Manifest for the complete field reference. Key fields at a glance:
{
"name": "@scope/my-module",
"version": "1.0.0",
"description": "Human-readable description for the Module Registry",
"kernelSdkVersion": "^1.0.0",
"entry": "",
"exposedComponent": "MyModuleComponent",
"route": "/my-module",
"icon": "LayoutDashboard",
"permissions": ["my-module.resource.read", "my-module.resource.write"],
"events": {
"publishes": ["my-module.item.created"],
"subscribes": ["auth.user.created"]
},
"healthCheck": "/health",
"dependencies": {
"@platform/sdk-core": "^1.0.0"
}
}
nameThe name field must follow npm-scope format: @scope/module-name (lowercase,
hyphen-separated). Regex: ^@[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$. Registration
fails with 400 Bad Request if the format is invalid.
permissionsEach permission string must follow lowercase.dot.notation format:
{module-slug}.{resource}.{action}. Example: crm.contacts.read. The kernel
auto-registers these in IAM when the module activates — you do not create them
manually.
federation.config.js — Module Federation Remote
Configures your module as a Module Federation 2.0 remote. The UI Shell (host) discovers and loads this entry point dynamically from the Module Registry.
import { EnterpriseSingletons } from '@kernel/build-config';
export default {
name: 'my_module', // must be a valid JS identifier (underscores)
exposes: {
'./Module': './src/pages/index.tsx', // the component the shell mounts
},
// Shared singleton dependencies (React, Zustand, etc.) are managed centrally
// in @kernel/build-config. Do NOT hardcode requiredVersion here.
shared: EnterpriseSingletons,
};
requiredVersion in sharedSingleton dependency versions are managed in
@kernel/build-config/src/mf-singletons.ts — the single source of truth across
all modules. Hardcoding versions here risks runtime conflicts that cause the
shell to raise an Error Boundary instead of loading your module.
package.json — Package Descriptor
{
"name": "@platform/module-my-module",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "platform-dev",
"build": "tsc && vite build",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"zustand": "5.0.3",
"@platform/sdk-core": "workspace:*",
"@platform/sdk-auth": "workspace:*",
"@platform/sdk-data": "workspace:*"
},
"devDependencies": {
"@platform/dev-server": "workspace:*",
"@platform/tsconfig": "workspace:*",
"@platform/sdk-testing": "workspace:*",
"@types/react": "19.0.8",
"@types/react-dom": "19.0.3",
"typescript": "5.8.2",
"vite": "6.2.0"
}
}
The platform-dev command (from @platform/dev-server) starts a Vite dev
server pre-configured with:
- Module Federation remote mode
- API proxy pointing to
http://localhost:8080(sandbox Gateway) - HMR and TypeScript incremental compilation
Source Directory (src/)
src/pages/index.tsx — Module Entry Point
This is the only component exposed via Module Federation. The UI Shell mounts this component when the user navigates to your module's route. It must be a valid React default export.
// The entry component receives no props from the shell.
// All contextual data (user, tenant, permissions) is accessed via SDK hooks.
export default function MyModuleComponent() {
return <div>My Module</div>;
}
The shell wraps every mounted component in:
- A per-module Error Boundary — a crash in your component shows an error card instead of breaking the shell
- A Suspense boundary — the shell shows a skeleton loader while your bundle downloads
src/engines/ — Business Logic
Pure TypeScript functions with no React dependencies. This layer contains your domain logic: validation rules, data transformations, computation. Keeping business logic here (rather than inside components) makes it independently testable with Vitest.
// src/engines/contacts.ts
export function validateContactEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function normalisePhoneNumber(phone: string): string {
return phone.replace(/\D/g, '');
}
src/api/ — SDK Wrappers
Thin API client functions that wrap SDK calls with module-specific defaults. Centralise error handling, default query parameters, and response mapping here — keep components clean.
// src/api/contacts.api.ts
import { useQuery, useMutation } from '@platform/sdk-data';
// Module slug is 'my-module', model name is 'contacts'.
// Resolves to: GET https://api.septemcore.com/api/v1/data/my-module/contacts
export const useContacts = () =>
useQuery('my-module', 'contacts', { limit: 50, order: 'created_at.desc' });
export const useCreateContact = () =>
useMutation('my-module', 'contacts', 'create');
src/events/ — Event Handlers
Handlers for Kafka/RabbitMQ events your module subscribes to. Each file should
handle one event type declared in manifest.events.subscribes.
// src/events/onUserCreated.ts
import { subscribe } from '@platform/sdk-events';
import type { AuthUserCreatedEvent } from '@platform/sdk-events/types';
// This handler is called when auth.user.created fires in Kafka.
// Only fires for events scoped to your tenant (SDK enforces tenant isolation).
subscribe('auth.user.created', async (event: AuthUserCreatedEvent) => {
// e.g., create a default contact record for every new user
console.log('New user registered:', event.data.userId);
});
Your module can only subscribe to events declared in
manifest.events.subscribes. Attempting to subscribe to an undeclared event at
runtime results in a 403 Forbidden from the Event Bus SDK. This is enforced
server-side, not just client-side.
src/components/ — Module-Private Components
React components used internally by your module. These are not shared via
Module Federation — only the entry component (src/pages/index.tsx) is exposed.
Component-to-component sharing across modules must go through the Event Bus or
Data primitives, never through MF shared state.
Migrations Directory (migrations/)
Required if your module uses the Data primitive (dataApi field in
manifest). Each migration is a plain SQL file following pressly/goose
numbering format.
-- migrations/001_create_contacts.sql
-- +goose Up
CREATE TABLE module_my_module.contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL, -- always required; RLS is added automatically by kernel
name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ -- soft delete support
);
-- RLS policy is added automatically by the kernel — DO NOT add it manually
-- +goose Down
DROP TABLE IF EXISTS module_my_module.contacts;
All module tables live in their own PostgreSQL schema:
module_{moduleSlug}. The kernel creates this schema automatically. Two modules
can both have a contacts table — they are module_crm.contacts and
module_sales.contacts, fully independent.
Automatic rollback of data migrations is prohibited — it risks destroying tenant
data. Write your migrations defensively: new columns should have DEFAULT NULL,
and your Go/TS code must handle both old and new schema shapes simultaneously.
See Architecture → Data Flow for the migration
failure handling policy.
Tests Directory (tests/)
Unit Tests (tests/unit/)
Use Vitest with @platform/sdk-testing mocks. All SDK primitives have pre-built
mock factories:
// tests/unit/contacts.engine.test.ts
import { describe, it, expect } from 'vitest';
import { validateContactEmail } from '../../src/engines/contacts';
describe('validateContactEmail', () => {
it('accepts a valid email', () => {
});
it('rejects a missing domain', () => {
expect(validateContactEmail('notanemail')).toBe(false);
});
});
To mock SDK calls in component tests:
import { mockKernel } from '@platform/sdk-testing';
// In your test setup
mockKernel({
data: { 'my-module/contacts': [] },
});
File Naming and Conventions
| Rule | Correct | Incorrect |
|---|---|---|
| React components | PascalCase.tsx | contactCard.tsx |
| Non-component TS files | camelCase.ts | ContactCard.ts |
| Event handlers | on{EventName}.ts | handle_user.ts |
| Migration files | NNN_description.sql | migration.sql |
| Manifest | module.manifest.json | manifest.json |
Module Lifecycle
When you register and activate your module, the kernel executes this sequence:
Next Steps
| Document | What you will learn |
|---|---|
| Module Manifest | Every field in module.manifest.json with types and constraints |
| Dev Environment | Full Docker Compose stack and environment variables |
| Concepts → Module Federation | How the UI Shell discovers and loads your module |
| Primitives → Data | Data Layer CRUD, query language, and analytics |