Skip to content

Commit 16a6a76

Browse files
feat: add createModule() factory and customModules support to framework
Enable developers to define new base modules beyond the core modules using `createModule()` and `ApiConfig.customModules`. Includes `registerCustomModules()` helper, example documents module in mocked integration, and documentation guide. (cherry picked from commit accf9ab)
1 parent 5a71c75 commit 16a6a76

22 files changed

Lines changed: 596 additions & 20 deletions

File tree

apps/api-harmonization/src/app.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Cache,
77
Carts,
88
Checkout,
9+
CustomModulesConfig,
910
Customers,
1011
Invoices,
1112
Notifications,
@@ -42,4 +43,5 @@ export const AppConfig: ApiConfig = {
4243
checkout: Checkout.CheckoutIntegrationConfig,
4344
auth: Auth.AuthIntegrationConfig,
4445
},
46+
customModules: CustomModulesConfig,
4547
};

apps/api-harmonization/src/app.module.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
Search,
2727
Tickets,
2828
Users,
29+
registerCustomModules,
2930
} from '@o2s/framework/modules';
3031

3132
import * as ArticleList from '@o2s/blocks.article-list/api-harmonization';
@@ -102,6 +103,8 @@ export const PaymentsBaseModule = Payments.Module.register(AppConfig);
102103
export const CheckoutBaseModule = Checkout.Module.register(AppConfig);
103104
export const AuthModuleBaseModule = AuthModule.Module.register(AppConfig);
104105

106+
const customModules = registerCustomModules(AppConfig);
107+
105108
@Module({
106109
imports: [
107110
HttpModule.register({ global: true }),
@@ -133,6 +136,8 @@ export const AuthModuleBaseModule = AuthModule.Module.register(AppConfig);
133136
CheckoutBaseModule,
134137
AuthModuleBaseModule,
135138

139+
...customModules,
140+
136141
PageModule.register(AppConfig),
137142
RoutesModule.register(AppConfig),
138143
LoginPageModule.register(AppConfig),
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
---
2+
sidebar_position: 400
3+
---
4+
5+
# Extending framework modules
6+
7+
The O2S framework ships with a set of core modules (invoices, tickets, orders, etc.). When your domain requires additional normalized data modules — such as documents, reports, or warranties — you can create **custom modules** that follow the same abstract-service/swappable-integration pattern as core modules.
8+
9+
## When to use Custom Modules
10+
11+
Use custom modules when:
12+
13+
- The core modules don't cover your domain entity
14+
- You want the same integration-swappable pattern (abstract service + concrete implementations)
15+
- Your module needs to be globally available for injection across the application
16+
17+
If you only need to **add fields** to an existing module, see [Extending an integration](./extending-integrations.md) instead.
18+
19+
## File structure overview
20+
21+
All custom module files live inside your **integration package** — the same place where core module implementations are located. For example, if you're adding a `documents` module to the mocked integration, the file structure would be:
22+
23+
```
24+
packages/integrations/mocked/src/modules/documents/
25+
├── documents.model.ts # Normalized data model
26+
├── documents.service.ts # Abstract service class (DI token)
27+
├── documents.service.mocked.ts # Concrete implementation
28+
├── documents.controller.ts # REST controller
29+
├── documents.mapper.ts # Data mapping / mock data
30+
└── index.ts # Barrel exports
31+
```
32+
33+
This mirrors the structure of core module implementations in the same integration (e.g. `packages/integrations/mocked/src/modules/invoices/`).
34+
35+
:::info Where to put files in different project setups
36+
- **Monorepo:** Create a new directory under your integration package, e.g. `packages/integrations/<your-integration>/src/modules/<module-name>/`
37+
- **CLI-bootstrapped projects:** Keep `apps/api-harmonization/` free of module definitions. Instead, create the abstract module (model, service, controller) in `packages/modules/<module-name>/` and the concrete implementation in a new integration package at `packages/integrations/<your-integration>/`. This keeps the NestJS app clean and follows the same separation of concerns as the core framework.
38+
:::
39+
40+
After creating the module files, you also need to update:
41+
- `packages/integrations/<your-integration>/src/modules/index.ts` — re-export the new module
42+
- `packages/integrations/<your-integration>/src/integration.ts` — add a `CustomModules` export
43+
- `packages/configs/integrations/src/models/` — add a config re-export (if using the configs package)
44+
- `apps/api-harmonization/src/app.config.ts` — add `customModules` to your `ApiConfig`
45+
- `apps/api-harmonization/src/app.module.ts` — register custom modules via `registerCustomModules()`
46+
47+
## Step-by-Step guide
48+
49+
### 1. Define your normalized data model
50+
51+
Create the model inside your integration package at `packages/integrations/<your-integration>/src/modules/<module-name>/`:
52+
53+
```typescript title="packages/integrations/<your-integration>/src/modules/documents/documents.model.ts"
54+
import { Models } from '@o2s/framework/modules';
55+
56+
export type DocumentType = 'CONTRACT' | 'REPORT' | 'POLICY';
57+
export type DocumentStatus = 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
58+
59+
export class Document {
60+
id!: string;
61+
title!: string;
62+
type!: DocumentType;
63+
status!: DocumentStatus;
64+
createdDate!: string;
65+
description!: string;
66+
}
67+
68+
export type Documents = Models.Pagination.Paginated<Document>;
69+
70+
export class GetDocumentListQuery {
71+
offset?: number;
72+
limit?: number;
73+
type?: DocumentType;
74+
status?: DocumentStatus;
75+
}
76+
77+
export class GetDocumentParams {
78+
id!: string;
79+
}
80+
```
81+
82+
### 2. Create an abstract service class
83+
84+
Define the service contract that integrations will implement. This file lives alongside the model in the same directory:
85+
86+
```typescript title="packages/integrations/<your-integration>/src/modules/documents/documents.service.ts"
87+
import { Injectable } from '@nestjs/common';
88+
import { Observable } from 'rxjs';
89+
import * as Model from './documents.model';
90+
91+
@Injectable()
92+
export abstract class DocumentService {
93+
protected constructor(..._services: unknown[]) {}
94+
95+
abstract getDocumentList(
96+
query: Model.GetDocumentListQuery,
97+
authorization?: string,
98+
): Observable<Model.Documents>;
99+
100+
abstract getDocument(
101+
params: Model.GetDocumentParams,
102+
authorization?: string,
103+
): Observable<Model.Document>;
104+
}
105+
```
106+
107+
### 3. Create a controller
108+
109+
Define REST endpoints for your module in the same directory:
110+
111+
```typescript title="packages/integrations/<your-integration>/src/modules/documents/documents.controller.ts"
112+
import { Controller, Get, Headers, Param, Query, UseInterceptors } from '@nestjs/common';
113+
import { Observable } from 'rxjs';
114+
import { LoggerService } from '@o2s/utils.logger';
115+
import * as Model from './documents.model';
116+
import { DocumentService } from './documents.service';
117+
118+
@Controller('/documents')
119+
@UseInterceptors(LoggerService)
120+
export class DocumentController {
121+
constructor(protected readonly documentService: DocumentService) {}
122+
123+
@Get()
124+
getDocumentList(
125+
@Query() query: Model.GetDocumentListQuery,
126+
@Headers('authorization') authorization?: string,
127+
): Observable<Model.Documents> {
128+
return this.documentService.getDocumentList(query, authorization);
129+
}
130+
131+
@Get(':id')
132+
getDocument(
133+
@Param() params: Model.GetDocumentParams,
134+
@Headers('authorization') authorization?: string,
135+
): Observable<Model.Document> {
136+
return this.documentService.getDocument(params, authorization);
137+
}
138+
}
139+
```
140+
141+
### 4. Create a barrel export
142+
143+
Export everything from the module directory:
144+
145+
```typescript title="packages/integrations/<your-integration>/src/modules/documents/index.ts"
146+
export * as Model from './documents.model';
147+
export { DocumentService as Service } from './documents.service';
148+
export { MockedDocumentService as MockedService } from './documents.service.mocked';
149+
export { DocumentController as Controller } from './documents.controller';
150+
```
151+
152+
Then add the re-export to your integration's modules index:
153+
154+
```typescript title="packages/integrations/<your-integration>/src/modules/index.ts"
155+
// ... existing module exports
156+
export * as Documents from './documents';
157+
```
158+
159+
### 5. Create a concrete implementation
160+
161+
Implement the abstract service with your data source. This file also lives in the same module directory:
162+
163+
```typescript title="packages/integrations/<your-integration>/src/modules/documents/documents.service.mocked.ts"
164+
import { Injectable } from '@nestjs/common';
165+
import { Observable, of } from 'rxjs';
166+
import { DocumentService } from './documents.service';
167+
import * as Model from './documents.model';
168+
169+
@Injectable()
170+
export class MockedDocumentService extends DocumentService {
171+
constructor() {
172+
super();
173+
}
174+
175+
getDocumentList(query: Model.GetDocumentListQuery): Observable<Model.Documents> {
176+
// Your implementation here
177+
}
178+
179+
getDocument(params: Model.GetDocumentParams): Observable<Model.Document> {
180+
// Your implementation here
181+
}
182+
}
183+
```
184+
185+
### 6. Register the module
186+
187+
Add a `CustomModules` export to your integration's main configuration file:
188+
189+
```typescript title="packages/integrations/<your-integration>/src/integration.ts"
190+
import { CustomModuleEntry } from '@o2s/framework/modules';
191+
192+
import { DocumentController } from './modules/documents/documents.controller';
193+
import { DocumentService } from './modules/documents/documents.service';
194+
import { MockedDocumentService } from './modules/documents/documents.service.mocked';
195+
196+
export const CustomModules: Record<string, CustomModuleEntry> = {
197+
documents: {
198+
name: 'my-integration',
199+
service: DocumentService,
200+
serviceImpl: MockedDocumentService,
201+
controller: DocumentController,
202+
},
203+
};
204+
```
205+
206+
If you use the `@o2s/configs.integrations` package, create a config re-export:
207+
208+
```typescript title="packages/configs/integrations/src/models/custom-modules.ts"
209+
import { CustomModules } from '@o2s/integrations.mocked/integration';
210+
211+
export const CustomModulesConfig = CustomModules;
212+
```
213+
214+
And add it to the configs index:
215+
216+
```typescript title="packages/configs/integrations/src/models/index.ts"
217+
// ... existing exports
218+
export { CustomModulesConfig } from './custom-modules';
219+
```
220+
221+
Then add `customModules` to your `ApiConfig`:
222+
223+
```typescript title="apps/api-harmonization/src/app.config.ts"
224+
import { CustomModulesConfig } from '@o2s/configs.integrations';
225+
import { ApiConfig } from '@o2s/framework/modules';
226+
227+
export const AppConfig: ApiConfig = {
228+
integrations: {
229+
// ... core module configs
230+
},
231+
customModules: CustomModulesConfig,
232+
};
233+
```
234+
235+
Finally, use `registerCustomModules()` in your app module to automatically register all custom modules:
236+
237+
```typescript title="apps/api-harmonization/src/app.module.ts"
238+
import { registerCustomModules } from '@o2s/framework/modules';
239+
import { AppConfig } from './app.config';
240+
241+
const customModules = registerCustomModules(AppConfig);
242+
243+
@Module({
244+
imports: [
245+
// ... core modules
246+
...customModules,
247+
// ... block modules
248+
],
249+
})
250+
export class AppModule {}
251+
```
252+
253+
## Creating an integration for your Custom Module
254+
255+
Other integrations can provide alternative implementations for your custom module. They just need to:
256+
257+
1. Import the abstract service class
258+
2. Create a concrete implementation extending it
259+
3. Export a `CustomModuleEntry` with their implementation
260+
261+
This follows the same pattern as core modules — the abstract service acts as the DI token, and the concrete implementation is swapped via configuration.
262+
263+
## Frontend SDK extension
264+
265+
To call your custom module's endpoints from the frontend, use `extendSdk()`:
266+
267+
```typescript
268+
import { extendSdk, getSdk } from '@o2s/framework/sdk';
269+
270+
const baseSdk = getSdk({ apiUrl: '/api' });
271+
272+
const sdk = extendSdk(baseSdk, {
273+
documents: {
274+
getList: (query) => baseSdk.makeRequest('/documents', { params: query }),
275+
getById: (id) => baseSdk.makeRequest(`/documents/${id}`),
276+
},
277+
});
278+
```
279+
280+
## Working example
281+
282+
The mocked integration includes a complete example of a custom "documents" module at `packages/integrations/mocked/src/modules/documents/`. This module demonstrates:
283+
284+
- Normalized data model (`documents.model.ts`)
285+
- Abstract service definition (`documents.service.ts`)
286+
- Mocked implementation (`documents.service.mocked.ts`)
287+
- REST controller (`documents.controller.ts`)
288+
- Mock data mapper (`documents.mapper.ts`)

apps/docs/docs/guides/integrations/overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ This section will help you understand some of the key concepts related to integr
1010
1. [Switching integrations](./switching-integrations.md) - the process of replacing one integration with another, without any changes to the frontend app.
1111
2. [Adding a new integration](./adding-new-integrations.md) - creating a new internal package for one of the modules from the framework, as well as how and where to add implementation.
1212
3. [Extending an integration](./extending-integrations.md) - for cases where you only want to modify an existing integration, and only add a new field or an endpoint.
13+
4. [Extending framework modules](./extending-framework-modules.md) - for creating entirely new base modules (e.g., documents, reports) beyond the 18 core modules, using the `createModule()` factory.

apps/docs/docs/integrations/commerce/medusa-js/how-to-setup.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -231,13 +231,13 @@ For production, you will typically:
231231

232232
Configure the following environment variables in your API Harmonization server:
233233

234-
| Name | Type | Description | Required |
235-
| ---------------------------- | ------ | -------------------------------------------------------------------- | -------- |
236-
| MEDUSAJS_BASE_URL | string | The base URL of your Medusa instance (e.g., `http://localhost:9000`) | yes |
237-
| MEDUSAJS_PUBLISHABLE_API_KEY | string | The publishable API key for Store API operations | yes |
238-
| MEDUSAJS_ADMIN_API_KEY | string | The admin API key for Admin API operations | yes |
239-
| DEFAULT_CURRENCY | string | The default currency code (e.g., `EUR`, `USD`, `PLN`) | yes |
240-
| DEFAULT_REGION_ID | string | The default Medusa region ID for cart creation (fallback when not provided by frontend) | no |
234+
| Name | Type | Description | Required |
235+
| ---------------------------- | ------ | --------------------------------------------------------------------------------------- | -------- |
236+
| MEDUSAJS_BASE_URL | string | The base URL of your Medusa instance (e.g., `http://localhost:9000`) | yes |
237+
| MEDUSAJS_PUBLISHABLE_API_KEY | string | The publishable API key for Store API operations | yes |
238+
| MEDUSAJS_ADMIN_API_KEY | string | The admin API key for Admin API operations | yes |
239+
| DEFAULT_CURRENCY | string | The default currency code (e.g., `EUR`, `USD`, `PLN`) | yes |
240+
| DEFAULT_REGION_ID | string | The default Medusa region ID for cart creation (fallback when not provided by frontend) | no |
241241

242242
You can obtain these values from your Medusa Admin Panel:
243243

apps/docs/docs/integrations/commerce/medusa-js/usage.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -184,15 +184,15 @@ Authorization: Bearer {token}
184184

185185
**Body:**
186186

187-
| Field | Type | Description | Required |
188-
| -------- | ------ | --------------------------------------------------- | -------- |
189-
| cartId | string | Existing cart ID (omit to create new cart) | No |
190-
| sku | string | Product variant SKU | Yes |
191-
| variantId | string | Medusa variant ID — maps directly to Medusa `variant_id` | Yes (Medusa) |
192-
| quantity | number | Quantity | Yes |
193-
| currency | string | Required when creating new cart | No |
194-
| regionId | string | Required when creating new cart | No |
195-
| metadata | object | Optional metadata | No |
187+
| Field | Type | Description | Required |
188+
| --------- | ------ | -------------------------------------------------------- | ------------ |
189+
| cartId | string | Existing cart ID (omit to create new cart) | No |
190+
| sku | string | Product variant SKU | Yes |
191+
| variantId | string | Medusa variant ID — maps directly to Medusa `variant_id` | Yes (Medusa) |
192+
| quantity | number | Quantity | Yes |
193+
| currency | string | Required when creating new cart | No |
194+
| regionId | string | Required when creating new cart | No |
195+
| metadata | object | Optional metadata | No |
196196

197197
**Example:**
198198

apps/docs/docs/main-components/harmonization-app/module-structure.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,16 @@ export class GetTicketListQuery
206206
}
207207
```
208208

209+
## Custom Modules
210+
211+
Beyond the core modules provided by the framework, you can create your own modules using the `createModule()` factory from `@o2s/framework/modules`. Custom modules follow the same abstract-service/swappable-integration pattern as core modules, enabling you to add domain-specific entities like documents, reports, or warranties.
212+
213+
Custom modules are registered via `ApiConfig.customModules` and automatically wired up using `registerCustomModules()`.
214+
215+
:::tip
216+
For a complete guide on creating custom modules, see [Extending framework modules](../../guides/integrations/extending-framework-modules.md).
217+
:::
218+
209219
## Integrations
210220

211221
An integration is a package that is responsible for:

0 commit comments

Comments
 (0)