Module development
Create maintainable product modules with a manifest, versioned SDK contracts, migrations, endpoints and optional Vue extensions.
FormPlatform Module Development Step-by-Step
中文:MODULE_DEVELOPMENT_STEP_BY_STEP.zh-CN.md
This tutorial creates an independent `Inventory` module from an empty directory. It includes configuration, three-provider migrations, an ORM entity, domain service, API, Server Action, client i18n, and deployment. The module is source-separated from the Host and references only the published SDK.
1. Choose a module when appropriate
Layout, field, and event changes need only a form. Use a module for dedicated tables, server APIs, actions/triggers, reusable controls, routes, or customer-specific logic. A module is trusted in-process code with full server privileges; isolate untrusted code in a separate service.
2. Prerequisites and layout
Build/publish FormPlatform so the SDK directory contains `FormPlatform.Sdk.dll/.xml` and `FormPlatform.Extension.Abstractions.dll/.xml`.
workspace/
FormPlatform/
Inventory/
InventoryModule.csproj
module.json
appsettings.inventory.json
InventoryModuleEntry.cs
InventoryMigrations.cs
InventoryService.cs
InventoryActions.cs
client/index.js
client/inventory.css
deploy.ps1
deploy.bat
uninstall.ps1
uninstall.bat
3. Create the project
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>1.0.0</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>InventoryModule</AssemblyName>
<RootNamespace>InventoryModule</RootNamespace>
<GenerateDependencyFile>true</GenerateDependencyFile>
<FormPlatformSdkDirectory Condition="'$(FormPlatformSdkDirectory)' == ''">..\FormPlatform\src\FormPlatform.Host\bin\Debug\net10.0</FormPlatformSdkDirectory>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<Reference Include="FormPlatform.Sdk" HintPath="$(FormPlatformSdkDirectory)\FormPlatform.Sdk.dll" Private="false" />
<Reference Include="FormPlatform.Extension.Abstractions" HintPath="$(FormPlatformSdkDirectory)\FormPlatform.Extension.Abstractions.dll" Private="false" />
</ItemGroup>
<ItemGroup>
<None Update="module.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.inventory.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="client\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
`Private=false` ensures the Host supplies the single shared SDK and Abstractions assemblies.
4. Create the manifest
{
"manifestVersion": 1,
"name": "Inventory",
"version": "1.0.0",
"entryAssembly": "InventoryModule.dll",
"type": "InventoryModule.InventoryModuleEntry",
"sdk": { "minimum": "1.0.0", "maximumExclusive": "2.0.0" },
"platform": { "minimum": "1.0.0", "maximumExclusive": "2.0.0" },
"navigation": [
{ "token": "inventory-management", "titleKey": "menu.inventory.management", "icon": "📦", "isGroup": true },
{ "token": "inventory-items", "titleKey": "menu.inventory.items", "target": "/inventory", "icon": "📋", "parentToken": "inventory-management" }
],
"ownedSystemForms": []
}
Names use only alphanumerics, `.`, `_`, and `-`, with a 100-character maximum. Manifest version must match assembly Major/Minor/Build. Ranges are minimum-inclusive and maximum-exclusive.
`navigation` is the module's complete declarative menu ownership list. Every entry has a stable `token`, `titleKey`, and `icon`; a normal link also has an application-relative `target`. A menu group uses `isGroup: true`; its child links use `parentToken`. The host inserts missing entries and moves a module-owned child below its declared parent. `ownedSystemForms` lists only fixed IDs of system forms that this module can regenerate; retain explicit empty arrays when neither resource is owned.
5. Add configuration and an ORM entity
{
"Inventory": { "DefaultPageSize": 25, "MaximumPageSize": 200 },
"DataAccess": {
"Entities": {
"InventoryItem": {
"Schema": "public",
"TableName": "inventory_item",
"Attributes": [
{ "PropertyName": "id", "ColumnName": "id", "ValueKind": "String", "IsNullable": false, "IsKey": true, "IsGenerated": true, "CanWrite": false, "MaxLength": 36 },
{ "PropertyName": "name", "ColumnName": "name", "ValueKind": "String", "IsNullable": false, "MaxLength": 200 },
{ "PropertyName": "quantity", "ColumnName": "quantity", "ValueKind": "Int32", "IsNullable": false },
{ "PropertyName": "updatedBy", "ColumnName": "updated_by", "ValueKind": "String", "IsNullable": false, "MaxLength": 36 },
{ "PropertyName": "updatedAt", "ColumnName": "updated_at", "ValueKind": "DateTimeOffset", "IsNullable": false, "CanWrite": false }
]
}
}
}
}
Module configuration is added before Core binds DataAccess options. Entity and property names are stable logical contracts, not UI labels.
6. Add three-provider migrations
Implement `IDatabaseMigrationModule` with module name `Inventory`, data source `Management`, and immutable IDs such as `001_create_inventory_item`. Provide equivalent PostgreSQL, MySQL, and SQL Server DDL, including conditional indexes and provider-appropriate defaults.
Never edit a released `001`; add `002_...`. Review MySQL implicit commits and conditional-index syntax instead of assuming every provider supports `IF NOT EXISTS`.
7. Implement the domain service
Consume SDK ORM interfaces, not Host implementation. Methods accept current user ID and `CancellationToken`; apply owner/authorization filters on the server. Use one unit of work for related reads, validation, and writes, then commit explicitly.
public sealed class InventoryService(IUnitOfWorkFactory workFactory, IDynamicRepository repository)
{
public async Task<object> CreateAsync(string userId, InventorySaveRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Name))
throw new PlatformApiException(400, "inventory.nameRequired", "inventory.nameRequired", "Name is required.");
await using var work = await workFactory.BeginAsync("Management", ct);
var row = new DynamicEntity("InventoryItem");
row["name"] = request.Name.Trim();
row["quantity"] = request.Quantity;
row["updatedBy"] = userId;
var saved = await repository.InsertAsync(work, row, ct);
await work.CommitAsync(ct);
return saved;
}
}
Use the SDK XML documentation and Todo/ResourceBooking source for exact repository/query APIs. Never convert request sort/filter text directly into SQL.
8. Actions and triggers
Implement `IServerActionsProvider` when form events/triggers need server behavior. Register stable names and return structured results. Re-read identity on the server; never trust a client `userId`. Substituted values still require typed validation. Use `PlatformApiException` or SDK trigger results for failure.
9. Implement the entry point
public sealed class InventoryModuleEntry : IFormPlatformSdkModule
{
public void ConfigureConfiguration(ConfigurationManager configuration, IHostEnvironment environment)
{
var directory = Path.GetDirectoryName(typeof(InventoryModuleEntry).Assembly.Location)!;
configuration.AddJsonFile(Path.Combine(directory, "appsettings.inventory.json"), false, true);
}
public void ConfigureServices(IServiceCollection services, IConfiguration configuration, IHostEnvironment environment)
{
services.Configure<InventoryOptions>(configuration.GetSection("Inventory"));
services.AddSingleton<InventoryService>();
services.AddSingleton<IServerActionsProvider, InventoryActions>();
services.AddSingleton<IDatabaseMigrationModule, InventoryMigrations>();
}
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
var api = endpoints.MapGroup("/api/inventory/items").RequireAuthorization();
api.MapGet("/", InventoryEndpoints.QueryAsync);
api.MapPost("/", InventoryEndpoints.CreateAsync);
api.MapPut("/{id}", InventoryEndpoints.UpdateAsync);
api.MapDelete("/{id}", InventoryEndpoints.DeleteAsync);
}
}
Hook order is Configuration, Services, Application, Endpoints. Implement only what is needed. Middleware affects the entire Host and requires special care.
10. API rules
Namespace APIs under `/api/{module}` and pages under `/{module}`. Apply authorization explicitly. Use 200/201/204 and return resource IDs/Location for creation. Throw `PlatformApiException` for reusable failures; allow Host mapping of `KeyNotFoundException` where appropriate. Forward cancellation throughout.
11. Forms versus a client route
Prefer Data Models, system forms, and DataGrid for CRUD. Use a standalone client route for calendars, boards, or graphics, while editing can still open ordinary forms. ResourceBooking demonstrates this hybrid.
System-form initialization is idempotent. Do not overwrite user-owned forms on every startup. Store system text as `@inventory.key`.
12. Client extension
`client/index.js` exports an installer:
export default {
install(api) {
api.registerMessages('Inventory', {
en: { inventory: { title: 'Inventory' } },
'zh-CN': { inventory: { title: '库存' } }
})
api.registerRoute({
path: '/inventory',
component: () => import('/extension-assets/Inventory/inventory-page.js')
})
api.extendActionApi('refreshInventory', () =>
window.FormPlatform.API.fetch('/api/inventory/items'))
}
}
The extension API registers controls, routes, Action APIs, Runtime APIs, messages, and app installers. A custom control uses mock data in `designerMode`, calls APIs through `window.FormPlatform.API.fetch`, and keeps business state in Vue reactivity rather than direct DOM mutations.
13. Enable the client module
{
"FormPlatformExtensions": {
"ModulesDirectory": "Modules",
"ClientModules": ["/extension-assets/Inventory/index.js"]
}
}
Extensions load before Vue and initial Router navigation, preserving direct module-route navigation.
14. Build and deploy
dotnet build InventoryModule.csproj -c Debug `
-p:FormPlatformSdkDirectory="C:\path\FormPlatform\bin\Debug\net10.0"
Deploy DLL, deps.json, manifest, module configuration, and client assets under `FormPlatform/Modules/Inventory`. Do not copy SDK/Host DLLs into the package. Restart the Host for a server DLL change; a pure client asset change normally needs only refresh/hard refresh.
Windows deployment launcher standard
Every Windows-targeting module must include both `deploy.ps1` and `deploy.bat`. `deploy.ps1` is the canonical, automation-friendly implementation; `deploy.bat` is a small Command Prompt/Explorer launcher that keeps the working directory stable, forwards all arguments, and returns the PowerShell exit code:
@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0deploy.ps1" %*
exit /b %ERRORLEVEL%
For example, an administrator can run `deploy.bat -Configuration Release -FormPlatformDirectory C:\inetpub\FormPlatform` without having to compose a PowerShell command. Keep deployment logic in `deploy.ps1`; do not duplicate it in the batch file.
Uninstallation standard
Every module must also provide `uninstall.ps1` and `uninstall.bat`, following the same wrapper rule. A normal uninstall removes only `FormPlatform/Modules/<module-name>` after the Host has stopped. `-RemoveMenuEntries` removes declared leaf tokens and then removes a declared group only when it is empty; it never removes administrator-added links. `-RemoveSystemForms` removes only `ownedSystemForms`. Both options queue a provider-neutral host-side cleanup request that runs through `IFormStore` on the next startup. It must preserve database tables and migration history by default: deleting application data silently makes a later reinstall inconsistent or destructive. A data-purge parameter is allowed only when it is explicit (for example `-RemoveDatabase`), requires an explicit provider and connection string, drops the module's schema, and deletes the corresponding migration history as one deliberate purge operation. PostgreSQL/SQL Server may use a transaction; MySQL DDL implicitly commits, so it must stop and report the first failure. Never infer destructive credentials from a secrets file.
@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0uninstall.ps1" %*
exit /b %ERRORLEVEL%
15. Debugging order
Build Host and verify current SDK files; build module against that exact path; compare assembly and manifest versions; deploy the complete package; restart and inspect module/migration logs; call APIs directly; inspect `/api/platform/client-extensions`; verify JS/CSS network responses and route registration; test Designer mock data and Viewer runtime data.
Common failures include old Host/SDK references (`TypeLoadException`), manifest mismatch, unloaded Entity configuration, edited migration checksums, authentication/policy failures, and routes registered after initial navigation.
16. Versioning
Use patch for compatible fixes, minor for compatible additions, and major for breaking contracts. Synchronize project version, manifest, compatibility ranges, changelog, and all-module CI. Increase SDK minimum when needed; do not widen maximum without validation.
17. Completion checklist
- Only SDK/Abstractions references, both `Private=false`.
- Manifest, assembly, and ranges match.
- Immutable migrations cover three providers.
- Entity metadata matches schema and generated/write flags.
- APIs authorize, filter ownership, forward cancellation, and use common errors.
- Actions/triggers never trust client identity.
- Designer avoids runtime APIs; Preview/Viewer value semantics agree.
- English and Chinese messages/fallbacks exist.
- Package contains no private Host/SDK copy.
- PostgreSQL integration and critical Playwright flows are covered.
For a source-complete Chinese Todo walkthrough, see the dedicated tutorial. ResourceBooking is the hybrid reference module beside the FormPlatform repository.