Skip to main content

Creating Plugins

LAMISPlus uses a plugin architecture where each domain feature is an independent microfrontend paired with a backend Spring Boot module. This guide covers creating both halves.

Frontend Plugin (Remote App)

Using the Script

The fastest way to scaffold a new frontend plugin from the ui/ directory:

cd ui
./create-plugin.sh <app-name> [port]

Examples:

./create-plugin.sh radiology 3007 # Creates radiology plugin on port 3007
./create-plugin.sh sync_monitor # Auto-selects next available port

The script automatically:

  • Creates the plugin folder from the template/ scaffold
  • Registers it in Core's plugin registry (core/src/config/pluginRegistry.ts)
  • Adds pnpm workspace scripts (dev:radiology, restart:radiology, etc.)
  • Runs pnpm install

After Creation

# Start your new plugin
pnpm dev:radiology

# Restart Core to pick up the new remote
pnpm restart:core

Your plugin is accessible at:

  • http://localhost:3000/radiology (through Core)
  • http://localhost:3007 (standalone)

Plugin Folder Structure

{plugin-name}/
├── src/
│ ├── App.tsx
│ ├── bootstrap.tsx # Async MF boundary (do not edit)
│ ├── routes.tsx # Route definitions with ProtectedRoute gates
│ ├── index.ts # Entry point (do not edit)
│ ├── features/
│ │ └── {feature}/
│ │ ├── components/
│ │ ├── pages/
│ │ ├── services/ # API calls via axiosInstance
│ │ ├── slices/ # Redux slices
│ │ └── types/
│ ├── components/common/ # Reuse Core components where possible
│ ├── config/
│ │ ├── menus/ # sidebar-menu.json
│ │ ├── icons.tsx
│ │ └── constants.ts # API endpoint paths
│ ├── hooks/
│ ├── store/
│ │ └── rootReducer.ts # Combine plugin reducers here
│ └── types/
├── module-federation.config.ts
├── rspack.config.ts
├── tsconfig.json
└── package.json

What Plugins Expose

Every plugin exposes these surfaces to Core:

exposes: {
'./{PluginName}App': './src/App', // Main component
'./menu': './src/config/menus/menu.json',
'./icons': './src/config/icons',
'./routes': './src/routes',
}

What Plugins Consume from Core

import { useAppDispatch, useAppSelector } from 'core/hooks';
import { axiosInstance } from 'core/axios';
import { storage } from 'core/storage';

Never use localStorage directly. Always use core/storage. Never create a new axios instance, always import from core/axios.

Adding Routes

Wrap every route in PluginWrapper + ProtectedRoute:

// routes.tsx
import { PluginWrapper, ProtectedRoute } from '@/components/common';

export const routes: PluginRouteConfig = {
basePath: '/radiology',
allowedRoles: ['ADMIN', 'USER'],
routes: [
{
index: true,
element: (
<PluginWrapper>
<ProtectedRoute requiredPermission="RADIOLOGY_VIEW">
<RadiologyDashboard />
</ProtectedRoute>
</PluginWrapper>
),
},
{
path: 'orders/create',
element: (
<PluginWrapper>
<ProtectedRoute requiredPermission="RADIOLOGY_CREATE">
<CreateRadiologyOrder />
</ProtectedRoute>
</PluginWrapper>
),
},
],
};

Injecting Redux Reducers

Call injectReducers when the plugin initialises:

// routes.tsx or App.tsx
import { injectReducers } from 'core/store';
import { radiologyReducer } from './store/rootReducer';

injectReducers({ radiology: radiologyReducer });

Backend Plugin

1. Create the Maven Module

Add the module to the root pom.xml:

<modules>
<module>api/lamisplus-core</module>
<module>api/lamisplus-radiology</module> <!-- new -->
</modules>

2. Plugin pom.xml

<dependency>
<groupId>io.github.gracemansam</groupId>
<artifactId>lamiplus_common_api</artifactId>
<version>1.0.7</version>
<scope>provided</scope> <!-- provided, not compile, avoids classloader conflicts -->
</dependency>

3. Implement Plugin Interface

@Component
public class RadiologyPlugin implements Plugin {

@Override
public String getPluginId() { return "radiology"; }

@Override
public String getName() { return "Radiology Management"; }

@Override
public String getVersion() { return "1.0.0"; }

@Override
public String getBasePath() { return "radiology"; }

@Override
public List<PluginPermission> getSecurityPermissions() {
return List.of(
createPermission("RADIOLOGY_VIEW", "View radiology orders", "Radiology", "READ"),
createPermission("RADIOLOGY_CREATE", "Create radiology orders", "Radiology", "CREATE"),
createPermission("RADIOLOGY_UPDATE", "Update radiology orders", "Radiology", "UPDATE"),
createPermission("RADIOLOGY_DELETE", "Delete radiology orders", "Radiology", "DELETE")
);
}
}

4. Create Controller

@RestController
@RequestMapping("/api/plugin/radiology")
@RequiredArgsConstructor
public class RadiologyController {

private final RadiologyService radiologyService;

@GetMapping("/orders")
@PreAuthorize("hasAuthority('RADIOLOGY_VIEW')")
public List<RadiologyOrderDTO> getOrders() {
return radiologyService.findAll();
}

@PostMapping("/orders")
@PreAuthorize("hasAuthority('RADIOLOGY_CREATE')")
public RadiologyOrderDTO createOrder(@Valid @RequestBody RadiologyOrderDTO dto) {
return radiologyService.create(dto);
}
}

5. Tenant-Aware Repository

@Repository
public interface RadiologyOrderRepository
extends PluginTenantAwareRepository<RadiologyOrder, UUID> {

// tenant_id filtered automatically
List<RadiologyOrder> findByPatientUuid(UUID patientUuid);
List<RadiologyOrder> findByArchivedOrderByCreatedAtDesc(int archived);
}

6. Declare Permissions in YAML (Alternative)

Instead of (or in addition to) getSecurityPermissions(), you can declare permissions in src/main/resources/plugin-permissions.yml:

pluginId: radiology
permissions:
- permissionCode: RADIOLOGY_VIEW
description: View radiology orders
category: Radiology
action: READ
- permissionCode: RADIOLOGY_CREATE
description: Create radiology orders
category: Radiology
action: CREATE

7. Build and Install

# Build plugin JAR
cd api/lamisplus-radiology
mvn clean package -DskipTests

# Install via API (requires SUPER_ADMIN token)
curl -X POST http://localhost:8789/api/v1/core/plugin/admin/install \
-H "Authorization: Bearer YOUR_SUPER_ADMIN_TOKEN" \
-H "X-Tenant-Id: _system_" \
-F "file=@target/lamisplus-radiology-1.0.0.jar"

8. Enable for a Tenant

curl -X POST http://localhost:8789/api/v1/core/plugin/radiology/enable \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-H "X-Tenant-Id: hospital_a"

Checklist for a New Plugin

Backend

  • Plugin interface implemented with correct pluginId and basePath
  • All permissions declared (YAML or getSecurityPermissions())
  • Every controller method has @PreAuthorize("hasAuthority('...')")
  • All repositories extend PluginTenantAwareRepository (not JpaRepository)
  • Multi-step write operations are @Transactional (Spring annotation, not Jakarta)
  • lamiplus_common_api at provided scope only

Frontend

  • Plugin registered in core/src/config/pluginRegistry.ts
  • Every route wrapped in <PluginWrapper><ProtectedRoute requiredPermission="...">
  • All API calls use axiosInstance from core/axios
  • Redux reducers injected via core/store's injectReducers
  • No localStorage direct access
  • No any types on API responses
  • catch blocks call toast.error(getErrorMessage(err)), never silent
  • Permission codes in frontend match exactly what the backend declares