MenuProviderExtension

MenuProviderExtension is the plugin-facing way to add menu items to the platform's navigation at runtime. Unlike the static menus.json manifest contribution, this SPI fires on every menu-build request and receives live user and tenant context — enabling role-gated navigation, feature-flag-driven menu trees, and tenant-scoped menu customization without touching the plugin manifest.

This is the correct contract for any menu entry that depends on who the current user is, what tenant is active, or what runtime settings are in play. If the menu entry is always visible to everyone and never changes, menus.json is simpler; if visibility depends on context, use this SPI.

Concept

The platform's menu-build pipeline collects contributions from two sources: static menus.json entries declared in the plugin manifest, and dynamic providers registered as MenuProviderExtension implementations. Providers are discovered via pf4j at plugin load time and invoked after static entries are resolved. The platform calls isActive(context) first — if it returns false, getMenuItems(context) is never called and the provider contributes nothing for that request.

MenuContext carries the full identity of the current request: tenantId, userId, userRoles, userPermissions, the originating pluginId, its namespace, and a settings map for runtime flags. Two convenience methods — hasRole(String) and hasPermission(String) — let providers gate items inline without manual list iteration.

MenuItem is the unit of contribution. Items nest via children, carry their own requiredRoles and requiredPermissions declarations (enforced by the platform renderer), and support target for external links. The metadata map is forwarded to the frontend renderer for extension-specific decoration.

Interface signature

package com.auraboot.framework.plugin.extension;

public interface MenuProviderExtension extends ExtensionPoint {

    /**
     * The menu group this provider contributes to.
     * Common groups: "main-sidebar", "header-menu", "user-menu", "settings-menu"
     */
    String getMenuGroup();

    /**
     * Returns the items to add for the given context.
     */
    List<MenuItem> getMenuItems(MenuContext context);

    /**
     * Guards the provider. Return false to contribute nothing.
     * Default: true (always active).
     */
    default boolean isActive(MenuContext context) {
        return true;
    }

    /**
     * Ordering within the group. Lower values appear first.
     * Default: 100.
     */
    default int getOrder() {
        return 100;
    }

    record MenuContext(
            Long tenantId,
            String pluginId,
            String namespace,
            Long userId,
            List<String> userRoles,
            List<String> userPermissions,
            Map<String, Object> settings
    ) {
        public boolean hasRole(String role) { ... }
        public boolean hasPermission(String permission) { ... }
    }

    record MenuItem(
            String key,
            String label,
            String icon,
            String path,
            String target,          // default "_self"
            int order,              // default 100
            List<MenuItem> children,
            List<String> requiredRoles,
            List<String> requiredPermissions,
            Map<String, Object> metadata
    ) {}
}

Implementing a provider

@Extension
public class BillingMenuProvider implements MenuProviderExtension {

    @Override
    public String getMenuGroup() {
        return "main-sidebar";
    }

    @Override
    public boolean isActive(MenuContext context) {
        // Only show when the current user holds the billing.invoice.view permission
        return context.hasPermission("billing.invoice.view");
    }

    @Override
    public List<MenuItem> getMenuItems(MenuContext context) {
        return List.of(
            MenuItem.builder()
                .key("billing-invoices")
                .label("Invoices")
                .icon("receipt")
                .path("/billing/invoices")
                .order(100)
                .requiredPermissions(List.of("billing.invoice.view"))
                .build(),
            MenuItem.builder()
                .key("billing-payments")
                .label("Payments")
                .icon("credit-card")
                .path("/billing/payments")
                .order(110)
                .requiredPermissions(List.of("billing.payment.view"))
                .build()
        );
    }

    @Override
    public int getOrder() {
        return 50; // appear before default providers
    }
}

Three things to notice:

  • @Extension is required. Without it pf4j will not discover the implementation and the provider is silently absent.
  • isActive is a guard, not a filter. Use it to skip the entire provider cheaply. Use requiredPermissions on individual MenuItem instances when only some items within a group need gating.
  • label should be an i18n key or a LocalizedText-resolved string. Hardcoding display text violates the platform's i18n contract (see Plugin Manifest).

Use cases and rules

ScenarioRecommended approach
Menu visible only to users with a specific permissionisActive checks context.hasPermission(...)
Nested submenu under an existing group entrySet children on the parent MenuItem
External link that opens in a new tabSet target = "_blank" on the item
Ordering relative to static menus.json entriesUse getOrder() — lower wins across both static and dynamic entries
Menu varies by tenant (e.g. different modules licensed)Read context.tenantId or tenant-scoped flags from context.settings()
Provider should never run in a specific namespaceGuard on context.namespace() inside isActive

Error semantics

getMenuItems and isActive are called synchronously during the request-scoped menu build. The platform wraps each provider invocation in a catch block — an unhandled exception from your provider will suppress that provider's contribution for the current request and log a warning; it will not fail the page load. This means silent failures are possible if an exception is swallowed.

Do not throw checked exceptions from getMenuItems. If a required dependency is unavailable, return an empty list and log the reason. Do not attempt to load data from a database or make remote calls inside getMenuItems — the method must be fast and side-effect-free.

Audit and correlation

Menu provider invocations are not individually traced in the platform audit log, because they carry no side effects. If your provider uses context.settings() to read a feature flag backed by a platform service, any state reads that service performs are audited under that service's own trail.

If you need to trace which provider contributed which item (e.g. for debugging a missing entry), set a metadata key on the MenuItem with a stable provider identifier. The platform forwards metadata to the frontend renderer and it appears in the React DevTools component tree.

Common pitfalls

PatternWhy it breaks
Omitting @Extensionpf4j does not discover the class; menu items are silently absent
Making I/O calls inside getMenuItemsBlocks the menu-build request path; use isActive to skip early and cache upstream data in plugin startup
Hardcoding display text in labelViolates i18n contract; text appears as raw string in non-default locales
Returning null from getMenuItemsPlatform expects a non-null List; return List.of() instead
Duplicating a key already present in menus.jsonResults in duplicate menu entries; use namespaced keys like billing-invoices not invoices
Checking permissions inside getMenuItems but not isActiveSlightly inefficient; use isActive for whole-provider guards to avoid building item lists that are discarded

Relationship to other SPIs

  • Plugin Manifestmenus.json static contribution vs. dynamic via this SPI; prefer static for unconditional entries.
  • Permissions — Menu visibility is permission-gated at two layers: isActive / requiredPermissions in the provider, and the platform's permission enforcement in the renderer. Both must align with registered permission codes.
  • CommandHandlerExtension — Menu items often invoke commands; the path on an item typically routes to a page that surfaces a command palette.

Enterprise note — Per-tenant menu entitlement gating (exposing menu groups only to tenants on specific subscription tiers), role-based dynamic menu trees driven by organization hierarchy, and A/B testing of menu variants with cohort assignment are commercial-only capabilities built on top of this SPI. The open-source MenuProviderExtension interface is identical across editions.

Next steps

  • Plugin Manifest — declare static menu entries and understand when dynamic providers are the right choice
  • Permissions — register permission codes that back your requiredPermissions declarations
  • CommandHandlerExtension — implement the commands that your menu items navigate to