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:

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:

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:

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:

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:

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 generic EntityCreatedEvent, EntityUpdatedEvent, and EntityDeletedEvent from @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 ProductDetailComponent in libs/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 UserListWidgetComponent in libs/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 PlatformSettingsModule in libs/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:

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 — isolatedModules rule: Always use export * from '...' in barrel files (not export { TypeName } from '...'). The app build uses isolatedModules, which requires export type for named type re-exports. Using export * 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

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:


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 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 @ModuleDecorator to execute and register the module with ModuleRegistryService. 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 children do NOT have a route property. 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'