Module Creation Guide
Documentation for Modules
Module Creation Guide
Step-by-step guide for creating a complete business module using the GoGrab framework building blocks. Target audience: AI agents and developers.
Prerequisites
Before starting, ensure the module's requirements and design are documented:
docs/english/modules/<module-name>/requirements.mddocs/english/modules/<module-name>/design.md
Reference implementation: @gograb-space/user-management in libs/modules/user-management/.
Phase 0: Discovery
Before writing any code, review existing modules to identify what you can reuse or integrate with.
Scan these directories:
libs/core/— Framework libraries (shared infrastructure: UI, workflow, media, taxonomy, fiscal, etc.)libs/modules/— Business modules (user-management, product-catalog, etc.)
For each relevant module, read its README.md at the library root (e.g., libs/core/taxonomy/README.md). Each README describes: what the module provides, its import path, exported components/services/types, and usage examples.
Key questions:
- Does an existing module already provide a model, service, or component I need?
- Which selector/picker components can I use in my forms? (e.g., category selector, brand selector, tax class selector)
- Does my entity need workflow, media, or entity lookup integration?
This phase prevents duplicate work and ensures proper integration with the ecosystem.
Phase 1: Scaffold
1.1 Generate Library
npx nx g @nx/angular:lib <module-name> \
--directory=libs/modules/<module-name> \
--importPath=@gograb-space/<module-name> \
--standalone \
--style=scss \
--changeDetection=OnPush \
--prefix=lib \
--skipTests
Note: Use
--directory=libs/modules/<module-name>(full path), NOT--directory=libs/modules.
1.2 Make Buildable
Add build target to project.json, create ng-package.json, package.json, tsconfig.lib.prod.json.
IMPORTANT: Update tsconfig.spec.json to include __tests__/ directory (the Nx generator only includes src/):
{
"include": [
"src/**/*.test.ts",
"src/**/*.spec.ts",
"__tests__/**/*.test.ts",
"__tests__/**/*.spec.ts"
]
}
Without this, vitest will find the test files but fail with "No test suite found in file" because the Angular compiler cannot process them.
libs/modules/<module-name>/ng-package.json:
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/libs/modules/<module-name>",
"lib": { "entryFile": "src/index.ts" }
}
libs/modules/<module-name>/package.json:
{
"name": "@gograb-space/<module-name>",
"version": "0.0.1",
"peerDependencies": {
"@angular/core": "^20.1.0",
"vite": "^6.3.5",
"@analogjs/vite-plugin-angular": "^1.0.0",
"@nx/vite": "^21.0.0"
},
"sideEffects": false
}
1.3 README
Create libs/modules/<module-name>/README.md (or libs/core/<module-name>/README.md for core libs). This is the primary technical reference for the module. It should concisely document: import path, purpose, exported types/components/services, usage examples, and dependencies. Keep it factual — other agents and developers will consult it during Phase 0 of future modules.
1.4 Directory Structure
Create the following directory layout:
libs/modules/<module-name>/
├── src/
│ ├── index.ts # Barrel exports
│ └── lib/
│ ├── models/ # Domain entities, API models, filters
│ │ └── index.ts
│ ├── services/ # API services
│ │ └── index.ts
│ ├── components/ # UI components (list, detail, form)
│ │ └── index.ts
│ ├── mocks/ # Mock data + mock API routes
│ │ └── index.ts
│ ├── events/ # Domain events
│ │ └── index.ts
│ ├── actions/ # Public actions (command pattern)
│ │ └── index.ts
│ ├── state/ # NGXS state (if needed)
│ │ └── index.ts
│ ├── data/ # Repositories
│ │ └── index.ts
│ ├── embeddables/ # Dashboard widgets
│ │ └── index.ts
│ ├── workflow/ # Workflow definitions (if applicable)
│ │ └── index.ts
│ ├── <module-name>.module.ts # @ModuleDecorator class
│ └── <module-name>.routes.ts # Route definitions
└── __tests__/ # Tests (mirror src structure)
├── models/
├── services/
├── components/
├── mocks/
├── events/
├── actions/
├── workflow/
├── media/
└── integration/
Phase 2: Models
2.1 Main Entity
src/lib/models/<entity>.model.ts
import { BaseEntity } from '@libs-core/shared-models';
export type <Entity>Status = 'active' | 'inactive' | 'draft';
export interface <Entity> extends BaseEntity {
name: string;
status: <Entity>Status;
// Module-specific fields
}
export const <ENTITY>_PERMISSIONS = {
VIEW: '<entities>:view',
CREATE: '<entities>:create',
UPDATE: '<entities>:update',
DELETE: '<entities>:delete',
} as const;
Conventions:
- Extend
BaseEntity(orUserProfile,TenantEntityif applicable) - Status as string union type
- Permissions as
resource:actionformat withas const
2.2 API Models
src/lib/models/<entity>-api.model.ts
import { ApiResponse } from '@libs-core/shared-models';
export interface Create<Entity>Request {
name: string;
// Required fields for creation
}
export interface Update<Entity>Request extends Partial<Create<Entity>Request> {
id: string;
}
export type <Entity>Response = ApiResponse<<Entity>>;
2.3 Filter Models
src/lib/models/<entity>-filters.model.ts
export interface <Entity>Filters {
search?: string;
status?: <Entity>Status[];
// Additional filter fields
}
export interface <Entity>Pagination {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrevious: boolean;
}
2.4 Tests
__tests__/models/<entity>.model.test.ts — Validate type constraints, permissions constants, filter helpers.
Phase 3: Mock API
3.1 Mock Data
src/lib/mocks/<entity>-mock-data.ts
import { <Entity> } from '../models';
export const MOCK_<ENTITIES>: <Entity>[] = [
{ id: '<ent>-001', name: 'Sample 1', status: 'active', /* ... */ },
{ id: '<ent>-002', name: 'Sample 2', status: 'draft', /* ... */ },
];
let mock<Entities>Store: <Entity>[] = [...MOCK_<ENTITIES>];
export function resetMock<Entities>(): void {
mock<Entities>Store = [...MOCK_<ENTITIES>];
}
3.2 Mock Routes
src/lib/mocks/<entity>-mock-api.ts
import { MockDefinition, MockRequest, MockResponse } from '@libs-core/mock-api';
export const <moduleName>MockAPI: MockDefinition = {
'GET /api/<entities>': async (req) => { /* list with pagination + filters */ },
'GET /api/<entities>/:id': async (req) => { /* get by ID */ },
'POST /api/<entities>': async (req) => { /* create */ },
'PUT /api/<entities>/:id': async (req) => { /* update */ },
'DELETE /api/<entities>/:id': async (req) => { /* delete */ },
};
Standard route pattern: REST endpoints with simulateDelay(), pagination, error handling, ApiResponse<T> wrapper.
3.3 Tests
__tests__/mocks/<entity>-mock-api.test.ts — Test each route: success cases, 404, validation errors, pagination.
Phase 4: Services
4.1 API Service
src/lib/services/<entity>-api.service.ts
import { Injectable } from '@angular/core';
import { BaseCrudApiService } from '@libs-core/module-base';
@Injectable({ providedIn: 'root' })
export class <Entity>ApiService extends BaseCrudApiService<<Entity>> {
protected readonly baseUrl = '/api/<entities>';
protected readonly entityName = '<Entity>';
// Override mapListResponse if API format differs from { items, totalItems }
// Override buildFilterParams for custom filter serialization
// Add domain-specific methods (e.g., updateStatus, bulkOps)
}
What BaseCrudApiService<T> provides for free:
getList(params)→ paginated listgetById(id)→ single entitycreate(data)→ createupdate(id, data)→ updatedelete(id)→ deleteentityType→ public getter exposingentityName(used by composables for event publishing)
4.2 Tests
__tests__/services/<entity>-api.service.test.ts — Test CRUD operations + custom methods against mock API.
Phase 5: Events
src/lib/events/<entity>.events.ts
import { DomainEvent } from '@libs-core/event-system';
export interface <Entity>CreatedPayload {
<entity>: <Entity>;
createdBy: string;
tenantId: string;
}
export class <Entity>CreatedEvent extends DomainEvent<<Entity>CreatedPayload> {
public override readonly eventName = '<entity>.created';
constructor(payload: <Entity>CreatedPayload) {
super(payload, 'normal', {
source: '<module-name>',
context: { tags: ['<entity>', 'create', 'lifecycle'] },
});
}
}
Standard events per entity: created, updated, deleted, status.changed.
Priority levels: 'low' (refresh), 'normal' (CRUD), 'high' (delete, security).
Note: The CRUD composables (
useCrudForm,useCrudList,useMasterDetail) automatically publish genericEntityCreatedEvent,EntityUpdatedEvent, andEntityDeletedEventfrom@libs-core/event-system. You only need to create custom domain events (like the example above) for module-specific business logic beyond basic CRUD (e.g.,status.changed, domain workflows).
Phase 6: Components (CRUD)
6.1 List Component
src/lib/components/<entity>-list/<entity>-list.component.ts
@Component({
selector: 'lib-<entity>-list',
standalone: true,
imports: [CommonModule, GgsCrudListShellComponent, GgsFilterPanelComponent, GgsFilterChipsComponent],
templateUrl: './<entity>-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class <Entity>ListComponent {
private readonly api = inject(<Entity>ApiService);
readonly crud = useCrudList<<Entity>>({
apiService: this.api,
basePath: '/<entities>',
entityName: '<Entity>',
columns: [
{ field: 'name', header: 'Name', sortable: true },
{ field: 'status', header: 'Status' },
],
filterDefinitions: [
{ field: 'status', label: 'Status', type: 'select',
options: [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
]},
],
syncWithUrl: true,
});
}
6.2 Detail Component
src/lib/components/<entity>-detail/<entity>-detail.component.ts
@Component({
selector: 'lib-<entity>-detail',
standalone: true,
imports: [CommonModule, GgsCrudDetailShellComponent],
templateUrl: './<entity>-detail.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class <Entity>DetailComponent {
private readonly api = inject(<Entity>ApiService);
readonly crud = useCrudDetail<<Entity>>({
apiService: this.api,
basePath: '/<entities>',
});
// crud.entity(), crud.refresh(), crud.navigateToEdit(), crud.navigateToList()
}
6.3 Form Component
src/lib/components/<entity>-form/<entity>-form.component.ts
@Component({
selector: 'lib-<entity>-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, GgsCrudFormShellComponent],
templateUrl: './<entity>-form.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class <Entity>FormComponent {
private readonly api = inject(<Entity>ApiService);
readonly crud = useCrudForm<<Entity>>({
createFormGroupFn: (fb, entity) => fb.group({
name: [entity?.name ?? '', [Validators.required]],
status: [entity?.status ?? 'draft', Validators.required],
}),
apiService: this.api,
basePath: '/<entities>',
});
}
Phase 7: Building Blocks Integration (as needed)
7.1 Workflow Engine
When: Entity has formal status transitions with permissions/confirmations.
Step 1: Create workflow definition.
src/lib/workflow/<entity>-workflow.definition.ts
import { WorkflowDefinition } from '@libs-core/shared-models';
export const <ENTITY>_STATUS_WORKFLOW: WorkflowDefinition = {
id: '<entity>-status',
entityType: '<entity>',
statusField: 'status',
states: [
{ id: 'draft', label: 'Draft', type: 'initial', color: 'warning' },
{ id: 'active', label: 'Active', type: 'intermediate', color: 'success' },
{ id: 'inactive', label: 'Inactive', type: 'final', color: 'neutral' },
],
transitions: [
{ id: 'publish', from: 'draft', to: 'active', label: 'Publish', permissions: ['<entities>:status'] },
{ id: 'archive', from: 'active', to: 'inactive', label: 'Archive', permissions: ['<entities>:status'],
requireConfirmation: true, confirmMessage: 'Are you sure?' },
],
};
Step 2: Create initializer.
src/lib/workflow/<entity>-workflow.initializer.ts
import { inject, EnvironmentProviders, makeEnvironmentProviders, provideEnvironmentInitializer } from '@angular/core';
import { WorkflowEngineService } from '@libs-core/workflow-engine';
import { <ENTITY>_STATUS_WORKFLOW } from './<entity>-workflow.definition';
export function provide<Entity>Workflow(): EnvironmentProviders {
return makeEnvironmentProviders([
provideEnvironmentInitializer(() => {
inject(WorkflowEngineService).registerWorkflow(<ENTITY>_STATUS_WORKFLOW);
}),
]);
}
Step 3: Register in routes as provider.
// In <module-name>.routes.ts
{ path: '', providers: [provide<Entity>Workflow()], children: [...] }
Step 4: Use <lib-status-transition> in detail component.
<lib-status-transition entityType="<entity>" [entity]="$any(item)" (transitionCompleted)="onTransitionCompleted($event)" />
Step 5: In onTransitionCompleted, persist status + refresh entity.
onTransitionCompleted(result: TransitionResult): void {
this.api.updateStatus(entity.id, result.toState)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.crud.refresh();
this.loadHistory();
});
}
Step 6: Add mock API routes for workflow history.
'GET /api/workflow-history/<entity>/:entityId': async (req) => { /* ... */ },
'POST /api/workflow-history': async (req) => { /* ... */ },
7.2 Media System
When: Entity has file attachments (images, documents, etc.).
Use <lib-entity-media-section> in detail and/or form:
<lib-entity-media-section
entityType="<entity>-photo"
[entityId]="item.id"
accept="image/jpeg,image/png,image/webp"
[maxSize]="2097152"
[multiple]="false"
[allowDelete]="true"
/>
In form (create vs edit):
@if (crud.isEditMode() && crud.entity(); as item) {
<lib-entity-media-section entityType="<entity>-photo" [entityId]="item.id" ... />
} @else {
<p class="form-hint">Files can be uploaded after creating the record.</p>
}
Add mock API routes:
'POST /api/media/upload': (req) => { /* return MediaFile */ },
'GET /api/media': (req) => { /* filter by entityType + entityId */ },
'DELETE /api/media/:mediaId': (req) => { /* delete */ },
7.3 Entity Lookup (Relations)
When: Entity has foreign key references to other entities (e.g., role assignment, category selection).
Single select — use <ggs-entity-lookup> directly:
<ggs-entity-lookup [loadFn]="categoryLoadFn" [displayFn]="categoryDisplayFn" (selectionChange)="onCategorySelected($event)" />
Multi select — create a wrapper component (pattern from MultiRoleLookupComponent):
@Component({
selector: 'lib-multi-<related>-lookup',
standalone: true,
imports: [FaIconComponent, GgsEntityLookupComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="multi-<related>-lookup">
<ggs-entity-lookup [loadFn]="loadFn" [displayFn]="displayFn" (selectionChange)="onSelected($event)" />
@if (selected().length > 0) {
<div class="selected-items">
@for (item of selected(); track item.id) {
<span class="chip">
{{ item.name }}
<button type="button" (click)="remove(item)">
<fa-icon [icon]="['fas', 'xmark']" />
</button>
</span>
}
</div>
}
</div>
`,
})
export class Multi<Related>LookupComponent {
initialItems = input<Related[]>([]);
showError = input(false);
itemsChanged = output<string[]>();
readonly selected = signal<Related[]>([]);
// ... loadFn filters out already-selected, displayFn shows name + description
}
7.4 Master-Detail (1:N Relations)
When: Entity has child entities (e.g., Product → ProductVariants, Order → OrderItems).
Step 1: Set up the composable in the parent detail component.
@Component({
selector: 'lib-<parent>-detail',
standalone: true,
imports: [CommonModule, GgsCrudDetailShellComponent],
templateUrl: './<parent>-detail.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class <Parent>DetailComponent {
private readonly parentApi = inject(<Parent>ApiService);
private readonly childApi = inject(<Child>ApiService);
readonly crud = useMasterDetail<<Parent>, <Child>>({
parentApiService: this.parentApi,
childApiService: this.childApi,
basePath: '/<parents>',
childBasePath: 'items',
childEntityName: '<Child>',
});
// Available signals: crud.parent(), crud.children(), crud.childrenLoading()
// Available methods: crud.navigateToChild(id), crud.createChild(), crud.refresh()
}
Step 2: Template with parent info + children table.
<ggs-crud-detail-shell [crud]="crud">
<!-- Parent information section -->
@if (crud.parent(); as parent) {
<div class="parent-info">
<h2>{{ parent.name }}</h2>
<!-- Parent fields... -->
</div>
<!-- Children table section -->
<section class="children-section">
<div class="section-header">
<h3>Items ({{ crud.children().length }})</h3>
<button class="btn btn-primary" (click)="crud.createChild()">
Add Item
</button>
</div>
@if (crud.childrenLoading()) {
<div class="loading-spinner">Loading...</div>
} @else if (crud.children().length === 0) {
<p class="empty-state">No items yet.</p>
} @else {
<table class="data-table">
<thead>
<tr>
<th>SKU</th>
<th>Name</th>
<th>Price</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (child of crud.children(); track child.id) {
<tr>
<td>{{ child.sku }}</td>
<td>{{ child.name }}</td>
<td>{{ child.price | currency }}</td>
<td>
<button (click)="crud.navigateToChild(child.id)">View</button>
</td>
</tr>
}
</tbody>
</table>
}
</section>
}
</ggs-crud-detail-shell>
Reference: See
ProductDetailComponentinlibs/modules/product-catalog/for a complete master-detail implementation with variants.
7.5 Embeddable Components (Widgets)
When: Module exposes components that can be embedded in other modules' dashboards or pages (e.g., a product lookup, a user list widget).
Contract: Every embeddable component is rendered by WidgetContainerComponent via NgComponentOutlet, which passes inputs dynamically. The component must declare these base inputs or Angular will throw NG0303:
import { ResolvedWidgetConfig } from '@libs-core/module-config';
@Component({
selector: 'lib-<entity>-widget',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `...`,
})
export class <Entity>WidgetComponent {
// Widget system inputs — required for grid integration
readonly widgetId = input<string>('');
readonly widgetConfig = input<ResolvedWidgetConfig>();
readonly title = input<string>('<Default Title>');
// Component-specific inputs
readonly limit = input<number>(10);
readonly showDetails = input<boolean>(true);
// Component outputs
readonly itemSelected = output<<Entity>>();
}
Register in module manifest (embeddableComponents array in ModuleManifest):
embeddableComponents: [
{
id: '<module>:<entity>-widget',
componentType: <Entity>WidgetComponent,
displayName: '<Entity> Widget',
description: 'Embeddable <entity> display for dashboards',
category: 'widget',
permissions: ['<entities>:read'],
inputs: [],
},
],
Reference: See
UserListWidgetComponentinlibs/modules/user-management/src/lib/embeddables/for a complete working example.
7.6 Hierarchical Navigation
When: Module has multiple sub-sections that should be grouped under a single parent menu item (e.g., "Settings" with Tax Classes, Brands, Categories, Tags).
Declare in @ModuleDecorator:
@ModuleDecorator({
// ...
navigation: {
menuItems: [
{
id: '<module>-parent',
label: '<Module Group>',
icon: '<icon>',
order: 900,
permissions: ['<module>:read'],
// Parent has NO route — only serves as a grouping container
children: [
{ id: '<module>-section-a', label: 'Section A', icon: 'icon-a', route: 'section-a', order: 1, permissions: ['<module>:section-a:manage'] },
{ id: '<module>-section-b', label: 'Section B', icon: 'icon-b', route: 'section-b', order: 2, permissions: ['<module>:section-b:manage'] },
],
},
],
},
})
Sidebar renders as:
▸ Module Group
├── Section A
└── Section B
Navigation entry in module-config-mock-api.ts:
{
id: '<module>-parent',
label: '<Module Group>',
icon: '⚙️',
// No route or routerLink — parent is not clickable
order: 900,
visible: true,
enabled: true,
moduleId: '<module-name>',
permissions: ['<module>:read'],
children: [
{
id: '<module>-section-a',
label: 'Section A',
icon: '📋',
route: '/<module>/section-a',
order: 1,
visible: true,
enabled: true,
moduleId: '<module-name>',
permissions: ['<module>:section-a:manage'],
},
// ... more children
],
},
When to use: Modules with 3+ distinct sub-sections under a single domain (e.g., platform-settings, configuration panels). For modules with a single primary list view, use flat navigation instead.
Reference: See
PlatformSettingsModuleinlibs/modules/platform-settings/for a complete working example.
7.7 Inter-Module Events
When: Module needs to react to events from other modules (e.g., invalidate cache when a referenced entity is deleted).
Subscribe in initialize():
import { EventBusService } from '@libs-core/event-system';
import { TaxRateChangedEvent, TaxClassDeletedEvent } from '@libs-core/fiscal';
import { BrandDeletedEvent } from '@libs-core/brand-registry';
initialize(): Observable<void> {
// React to tax rate changes — recalculate affected product prices
this.eventBus.subscribe(TaxRateChangedEvent).subscribe(event => {
this.logger.info('Tax rate changed, recalculating prices', {
taxClassId: event.payload.taxClass.id,
previousRate: event.payload.previousRate,
newRate: event.payload.newRate,
});
this.recalculatePricesForTaxClass(event.payload.taxClass.id);
});
// React to entity deletion — clear orphaned foreign key references
this.eventBus.subscribe(BrandDeletedEvent).subscribe(event => {
this.logger.warn('Brand deleted, clearing product references', { brandId: event.payload.brandId });
this.clearBrandReferences(event.payload.brandId);
});
return of(void 0);
}
Event naming convention: {source}.{entity}.{action} — see inter-module-communication.md for the complete event catalog.
Common subscription patterns:
- Entity deleted: Clear orphaned foreign key references in your module's data
- Entity updated: Refresh cached data (e.g., brand name changed → update product display)
- Status changed: Trigger cascading effects (e.g., tax rate changed → recalculate prices)
7.8 Dashboard Widget per Module
When: Module should contribute a widget to the main dashboard.
This combines Phase 7.5 (Embeddable Components) with dashboard configuration.
Step 1: Create the widget component following Phase 7.5 pattern.
Step 2: Register in module manifest's embeddableComponents:
embeddableComponents: [
{
id: '<module>:<entity>-summary',
componentType: <Entity>SummaryWidgetComponent,
displayName: '<Entity> Summary',
description: 'Dashboard widget showing <entity> metrics',
category: 'widget',
permissions: ['<entities>:read'],
inputs: [
{ name: 'limit', type: 'number', defaultValue: 5 },
],
},
],
Step 3: Add widget to dashboard page configuration in module-config-mock-api.ts:
// In the dashboard page's widgets array
{
id: 'w-<entity>-summary',
widgetType: '<module>:<entity>-summary',
title: '<Entity> Summary',
position: { row: 0, col: 0, width: 6, height: 2 },
config: { limit: 5 },
},
Phase 8: Routes
src/lib/<module-name>.routes.ts
import { Routes } from '@angular/router';
import { authGuard, contextGuard, permissionGuard } from '@libs-core/security';
export const <moduleName>Routes: Routes = [
{
path: '',
providers: [/* workflow providers if applicable */],
children: [
{ path: '', redirectTo: 'list', pathMatch: 'full' },
{
path: 'list',
loadComponent: () => import('./components/<entity>-list/<entity>-list.component').then(m => m.<Entity>ListComponent),
canActivate: [authGuard, contextGuard, permissionGuard('<entities>:view')],
data: { title: '<Entity> List', breadcrumb: '<Entities>', permissions: ['<entities>:view'] },
},
{
path: 'create',
loadComponent: () => import('./components/<entity>-form/<entity>-form.component').then(m => m.<Entity>FormComponent),
canActivate: [authGuard, contextGuard, permissionGuard('<entities>:create')],
data: { title: 'Create <Entity>', breadcrumb: 'Create', permissions: ['<entities>:create'], mode: 'create' },
},
{
path: ':id',
loadComponent: () => import('./components/<entity>-detail/<entity>-detail.component').then(m => m.<Entity>DetailComponent),
canActivate: [authGuard, contextGuard, permissionGuard('<entities>:view')],
data: { title: '<Entity> Details', breadcrumb: 'Details', permissions: ['<entities>:view'] },
},
{
path: ':id/edit',
loadComponent: () => import('./components/<entity>-form/<entity>-form.component').then(m => m.<Entity>FormComponent),
canActivate: [authGuard, contextGuard, permissionGuard('<entities>:update')],
data: { title: 'Edit <Entity>', breadcrumb: 'Edit', permissions: ['<entities>:update'], mode: 'edit' },
},
],
},
];
Guard chain (always in this order): authGuard → contextGuard → permissionGuard (→ roleGuard for admin routes).
Phase 9: Module Registration
src/lib/<module-name>.module.ts
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { BrowserModuleContract, ModuleManifest, ModuleDecorator } from '@libs-core/module-base';
import { LoggerService } from '@libs-core/log-system';
@ModuleDecorator({
id: '<module-name>',
version: '1.0.0',
name: '<Module Name>',
description: '<description>',
basePath: '<entities>',
repositories: [/* Repository classes */],
relations: [],
permissions: [
{ id: '<entities>:read', name: '<entities>:read', resource: '<entities>', action: 'read' },
{ id: '<entities>:create', name: '<entities>:create', resource: '<entities>', action: 'create' },
{ id: '<entities>:update', name: '<entities>:update', resource: '<entities>', action: 'update' },
{ id: '<entities>:delete', name: '<entities>:delete', resource: '<entities>', action: 'delete' },
],
navigation: {
menuItems: [
{ id: '<entities>-main', label: '<Entities>', icon: '<icon>', route: 'list', order: 100,
permissions: ['<entities>:read'] },
],
},
// For hierarchical navigation (multiple sub-sections), use children instead:
// navigation: {
// menuItems: [
// {
// id: '<module>-parent', label: '<Module Group>', icon: '<icon>', order: 100,
// permissions: ['<module>:read'],
// children: [
// { id: '<module>-section-a', label: 'Section A', icon: 'icon', route: 'section-a', order: 1, permissions: [...] },
// { id: '<module>-section-b', label: 'Section B', icon: 'icon', route: 'section-b', order: 2, permissions: [...] },
// ],
// },
// ],
// },
tags: ['<module-name>', 'crud'],
})
@Injectable({ providedIn: 'root' })
export class <ModuleName>Module implements BrowserModuleContract {
private readonly logger = inject(LoggerService).child({ source: '<ModuleName>Module' });
readonly manifest: ModuleManifest = {
id: '<module-name>',
version: '1.0.0',
// ... embeddableComponents, publicActions
};
readonly routes = <moduleName>Routes;
configure<T = Record<string, unknown>>(config: T): Observable<void> { return of(void 0); }
initialize(): Observable<void> { return of(void 0); }
start(): Observable<void> { return of(void 0); }
stop(): Observable<void> { return of(void 0); }
destroy(): Observable<void> { return of(void 0); }
}
Phase 10: Barrel Exports
src/index.ts
// Module contract
export * from './lib/<module-name>.module';
// Models
export * from './lib/models';
// Services
export * from './lib/services';
// Components
export * from './lib/components';
// Events
export * from './lib/events';
// Actions
export * from './lib/actions';
// Mocks
export * from './lib/mocks';
// Routes
export * from './lib/<module-name>.routes';
// Workflow (if applicable)
export * from './lib/workflow';
// Embeddables (if applicable)
export * from './lib/embeddables';
IMPORTANT —
isolatedModulesrule: Always useexport * from '...'in barrel files (notexport { TypeName } from '...'). The app build usesisolatedModules, which requiresexport typefor named type re-exports. Usingexport *avoids this entirely and is the pattern used throughout the workspace. Sub-barrel files (models/index.ts,events/index.ts, etc.) must follow the same rule:// models/index.ts — CORRECT export * from './entity.model'; export * from './entity-api.model'; // models/index.ts — WRONG (fails with TS1205 in app build) export { Entity, CreateEntityRequest } from './entity.model'; export { EntityResponse } from './entity-api.model';
Phase 11: Tests
Test Organization
| Layer | Test Location | Pattern |
|---|---|---|
| Models | __tests__/models/<entity>.model.test.ts |
Type constraints, permission constants |
| Mock API | __tests__/mocks/<entity>-mock-api.test.ts |
Route responses, pagination, errors |
| Services | __tests__/services/<entity>-api.service.test.ts |
CRUD ops via HttpTestingController |
| Events | __tests__/events/<entity>.events.test.ts |
Event construction, payload validation |
| Actions | __tests__/actions/<action>.action.test.ts |
Execute, validate, error handling |
| Components | __tests__/components/<component>.test.ts |
Rendering, user interaction |
| Workflow | __tests__/workflow/<entity>-workflow.test.ts |
States, transitions, guards |
| Media | __tests__/media/<entity>-media.test.ts |
Upload, gallery, delete |
| Integration | __tests__/integration/<feature>.test.ts |
Cross-layer flows |
Test Naming
- File:
<feature-name>.test.ts(not.spec.ts) - Describe: Feature or class name
- It: Behavior description (
should create entity with valid data)
Running Tests
npx nx test <module-name> # Run all module tests
npx nx test <module-name> --watch # Watch mode
Phase 12: Validation
Run all checks before considering the module complete:
# Lint
npx nx lint <module-name>
# Tests (target: 90%+ coverage)
npx nx test <module-name>
# Build (verifies module boundary compliance)
npx nx build <module-name>
Common issues:
@nx/enforce-module-boundaries: Ensure all imported libraries are buildableTS2561(excess properties): Cast withas Partial<Entity>when spreading extra fields- Missing barrel exports: Update every
index.tsin the chain No test suite found in file: Missing__tests__/**/*.test.tsintsconfig.spec.jsoninclude arraybundle initial exceeded maximum budget: Adding modules to the initial bundle increases its size. Adjust budgets inapps/gograb-admin/project.jsonas needed during developmentTS1205(isolatedModules): Named re-exports of types fail in the app build. Useexport * frominstead ofexport { TypeName } fromin barrel files (see Phase 10)- Accessibility (ESLint): Clickable non-interactive elements (
<div>,<li>,<span>) with(click)requirerole="button"(orrole="option"for list items),tabindex="0", and(keydown.enter)=.... Elements withrole="option"also require[attr.aria-selected]. Labels need[for]associated with a form control's[id] - Arrow functions in templates: Angular's template parser rejects
=>. Usecomputed()signals or component methods instead (e.g.,{{ items().map(i => i.name) }}→{{ itemNames() }}withitemNames = computed(...)) - CRUD composable API: Verify property names against the actual composable interfaces in
@libs-core/module-base. Key differences:basePath(notrouteBasePath),entityLoading(notloading),entityError(noterror),form()is a Signal (needs()in template),onSearch(notonSearchChange),onPageChange(notgoToPage),requestDelete(notdeleteEntityin list)
Phase 13: App Integration (gograb-admin)
CRITICAL: Without this phase, the module builds and tests pass but does not appear in the running application. This is the most common reason a newly created module is "invisible". All sub-steps are required.
Applies to: Business modules (
libs/modules/) only. Core libraries (libs/core/) that provide shared services/components without their own routes only need step 13.2 (mock API registration).
Quick Checklist
Before marking the module complete, verify ALL of these:
- 13.1 Module class imported in
apps/gograb-admin/src/app/core/modules.ts - 13.2 Mock API spread in
apps/gograb-admin/src/app/core/mocks/mock-config.ts - 13.3 Navigation entry added in
apps/gograb-admin/src/app/core/mocks/module-config-mock-api.ts - 13.4 Module activation entry added (same file,
modulesarray + legacy endpoint) - 13.6
npx nx build gograb-adminsucceeds - 13.6
npx nx serve gograb-adminshows module in sidebar
13.1 Register Module in Bundle
apps/gograb-admin/src/app/core/modules.ts
import { <ModuleName>Module } from '@gograb-space/<module-name>';
const coreModules = [
UserManagementModule,
<ModuleName>Module, // ← Add here
WidgetSystemModule,
];
void coreModules;
This forces the
@ModuleDecoratorto execute and register the module withModuleRegistryService. Without it, tree-shaking removes the module class from the bundle.
13.2 Register Mock API Endpoints
apps/gograb-admin/src/app/core/mocks/mock-config.ts
import { <moduleName>MockAPI } from '@gograb-space/<module-name>';
export const MOCK_DEFINITIONS = {
...createSecurityMocks(SECURITY_CONFIG),
...moduleConfigMockAPI,
...userManagementMockAPI,
...<moduleName>MockAPI, // ← Add here
};
Without this, API calls from the module return 404 in development mode.
13.3 Add Navigation Entry
apps/gograb-admin/src/app/core/mocks/module-config-mock-api.ts
In the navigation.groups[0].items array (hardcoded fallback config):
Flat navigation (single list page):
{
id: '<entities>',
label: '<Module Name>',
icon: '📦', // Use an appropriate emoji
route: '/<entities>',
order: <next-order>,
visible: true,
enabled: true,
moduleId: '<module-name>',
permissions: ['<entities>:view']
},
Hierarchical navigation (multiple sub-sections):
{
id: '<module>-parent',
label: '<Module Group>',
icon: '⚙️',
// No route — parent is not clickable, only a grouping container
order: <next-order>,
visible: true,
enabled: true,
moduleId: '<module-name>',
permissions: ['<module>:read'],
children: [
{
id: '<module>-section-a',
label: 'Section A',
icon: '📋',
route: '/<module>/section-a',
order: 1,
visible: true,
enabled: true,
moduleId: '<module-name>',
permissions: ['<module>:section-a:manage'],
},
{
id: '<module>-section-b',
label: 'Section B',
icon: '🏷️',
route: '/<module>/section-b',
order: 2,
visible: true,
enabled: true,
moduleId: '<module-name>',
permissions: ['<module>:section-b:manage'],
},
],
},
Key difference: Parent items with
childrendo NOT have arouteproperty. Only leaf children have routes.
13.4 Add Module Activation Entry
In the same file, add to the modules array:
{
moduleId: '<module-name>',
enabled: true,
config: {},
permissions: ['<entities>:view']
},
And in the legacy endpoint GET /api/:tenantId/modules/configuration:
'<module-name>': {
moduleId: '<module-name>',
enabled: true,
basePath: '/<entities>',
features: { crud: true, search: true, filters: true }
},
13.5 Verify App Integration
# Build the full app (verifies all imports resolve)
npx nx build gograb-admin
# Serve and verify in browser
npx nx serve gograb-admin
Verify: Navigation sidebar shows the new module entry. Clicking it navigates to the module's list page.
Quick Reference: Naming Conventions
| Item | Convention | Example |
|---|---|---|
| Module ID | kebab-case | product-catalog |
| Library path | libs/modules/<module-name>/ |
libs/modules/product-catalog/ |
| Import path | @gograb-space/<module-name> |
@gograb-space/product-catalog |
| Entity interface | PascalCase | Product |
| Status type | <Entity>Status |
ProductStatus |
| Permissions | <entities>:<action> |
products:create |
| Event name | <entity>.<action> |
product.created |
| Route path | <entities>/ |
products/ |
| Component selector | lib-<entity>-<view> |
lib-product-list |
| API URL | /api/<entities> |
/api/products |
| Mock data constant | MOCK_<ENTITIES> |
MOCK_PRODUCTS |
| Workflow ID | <entity>-status |
product-status |
| Test file | <feature>.test.ts |
product.model.test.ts |
MCP Server Alignment
When registering a module in the MCP server catalog, map to these concepts:
| Framework Concept | MCP Vocabulary |
|---|---|
| CRUD list/detail/form | ModulePageInfo[] (pages) |
| Permissions | permissions: string[] |
| Actions | ModuleCapability[] (capabilities) |
| Embeddable widgets | ModuleWidgetInfo[] (widgets) |
| API service methods | ModuleDataSourceInfo[] (dataSources) |
| Module config | ModuleConfigOption[] (configOptions) |
| Module template type | `'basic' |
| Module lifecycle | `'alpha' |