Skip to content

SPI Extensions

Q-Framework provides extension points through the SPI (Service Provider Interface) pattern. Behavior can be customized solely through interface implementations, without modifying framework internals.

SPI Design Principles

Q-Framework (defines interfaces)
        ↑ depends on
Application (provides implementations)
  • High-level framework does not depend on low-level adapters (DIP principle)
  • Implementations are discovered automatically at runtime
  • Multiple implementations can be registered for a single interface

QfUserProvider

Provides current request user information. Must be implemented.

java
@Component
public class MyUserProvider implements QfUserProvider {

    @QfAllowedDirectAccess(reason = "SPI implementation — must read user store directly")
    private final UserRepository userRepository;

    @Override
    public QfUser getUserById(Object userId) {
        return userRepository.findById(userId)
            .map(this::toQfUser)
            .orElse(null);
    }

    @Override
    public QfUser getCurrentUser(QfRequestContext context) {
        // Works with Spring Security, JWT, Session, or any approach
        String userId = extractUserIdFromContext(context);

        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UnauthorizedException());

        return QfUser.builder()
            .id(user.getId())
            .name(user.getName())
            .organizationId(user.getOrganizationId())
            .privileges(roleService.getPrivileges(user.getRoles()))
            .locale(user.getPreferredLocale())
            .build();
    }

    @Override
    public int priority() {
        return 100;  // higher value wins; default is 0
    }
}

QfUserProvider methods

MethodRequiredDescription
QfUser getUserById(Object userId)Resolves a user by identifier
QfUser getCurrentUser(QfRequestContext context)default: returns nullResolves the currently authenticated user from request context
int priority()default: 0Selection priority when multiple implementations exist; highest value wins

QfOrganizationProvider

Provides organizational hierarchy data. Required when using the organization model.

java
@Component
public class MyOrganizationProvider implements QfOrganizationProvider {

    @QfAllowedDirectAccess(reason = "SPI implementation — must read organization store directly")
    private final OrganizationRepository organizationRepository;

    @Override
    public List<QfOrganization> getAllOrganizations() {
        return organizationRepository.findAll()
            .stream()
            .map(this::toQfOrganization)
            .collect(Collectors.toList());
    }

    @Override
    public List<QfOrganization> getOrganizations(Object userId) {
        return organizationRepository.findByUserId(userId)
            .stream()
            .map(this::toQfOrganization)
            .collect(Collectors.toList());
    }

    // Optional: override default implementations as needed

    private QfOrganization toQfOrganization(OrganizationEntity org) {
        return QfOrganization.builder()
            .id(org.getId())
            .parentId(org.getParentId())
            .name(org.getName())
            .depth(org.getDepth())
            .build();
    }

    @Override
    public int priority() {
        return 100;  // higher value wins; default is 0
    }
}

QfOrganizationProvider methods

MethodRequiredDescription
List<QfOrganization> getAllOrganizations()Returns all organizations known to the application
List<QfOrganization> getOrganizations(Object userId)Returns organizations associated with the given user
List<QfOrganization> getDelegatedOrganizations(Object userId)default: empty listReturns organizations the user is explicitly delegated to manage (cross-org access)
List<QfOrganization> getAncestors(Object orgId, int maxDepth)default: in-memory traversalReturns ancestor organizations up to maxDepth levels above orgId
List<QfOrganization> getAllUnder(Collection<Object> orgIds)default: in-memory BFSReturns all descendant organizations (including seeds) reachable from given roots
int priority()default: 0Selection priority when multiple implementations exist; highest value wins

QfRuntimeInitializationHook

Performs additional work at runtime initialization time.

java
@Component
public class MyInitializationHook implements QfRuntimeInitializationHook {

    @Override
    public void onInitializationStarted(QfRuntimeContext runtimeContext) {
        // Before initialization: validation, preprocessing, etc.
        log.info("Q-Framework initialization starting");
    }

    @Override
    public void onInitializationSucceeded(QfRuntimeContext runtimeContext) {
        // After successful initialization: cache preloading, default data setup, etc.
        log.info("Q-Framework initialization succeeded");
    }

    @Override
    public void onInitializationFailed(QfRuntimeContext runtimeContext) {
        // On initialization failure: cleanup, alerting, degraded mode, etc.
        log.error("Q-Framework initialization failed");
    }
}

QfInitDataContributor

Provides initialization data for the frontend /qapi/init endpoint.

java
@Component
public class AppInitDataContributor implements QfInitDataContributor {

    @Override
    public Map<String, Object> getInitData(QfInitRequest request) {
        // Skip messages if client revision matches server
        if (request.messageRevision() != null &&
            request.messageRevision().equals(messageService.currentRevision())) {
            return Map.of("clientApps", clientAppService.getAll());
        }
        return Map.of(
            "clientApps", clientAppService.getAll(),
            "menus", menuService.getMenusForCurrentUser()
        );
    }

    @Override
    public int priority() {
        return 100;  // higher priority wins when keys conflict
    }
}

QfInitRequest fields

The request parameter carries client-side revision values for conditional data loading.

FieldTypeDescription
localeRevisionStringClient's current locale list revision
messageRevisionStringClient's current message revision for the active locale
setupRevisionStringClient's current setup data revision
rsaPublicKeyStringClient's ephemeral RSA public key (SPKI, Base64) for transport key exchange

When a revision matches the server-side value, the corresponding data section can be skipped (return null for that key) to reduce payload size.


QfDiagnosticListener

Receives diagnostic events from Q-Framework.

java
@Component
public class MyDiagnosticListener implements QfDiagnosticListener {

    @Override
    public void onError(DiagnosticEvent event) {
        log.error("[{}] {}", event.code(), event.message());
        monitoring.track("qf_error", Map.of("code", event.code()));
    }

    @Override
    public void onWarning(DiagnosticEvent event) {
        log.warn("[{}] {}", event.code(), event.message());
    }

    @Override
    public void onInfo(DiagnosticEvent event) {
        log.info("[{}] {}", event.code(), event.message());
    }
}

QfRuntimeConfigProvider

Dynamically provides runtime configuration from external sources (DB, Config Server, etc.).

java
@Component
public class DatabaseConfigProvider implements QfRuntimeConfigProvider {

    @QfAllowedDirectAccess(reason = "SPI implementation — must read config store directly")
    private final ConfigRepository configRepository;

    @Override
    public List<ApplicationConfiguration> load() {
        // load configuration from DB as structured configuration sources
        Map<String, Object> props = configRepository.findAll()
            .stream()
            .collect(Collectors.toMap(Config::getKey, Config::getValue));
        return List.of(new ApplicationConfiguration(props, "database"));
    }
}

QfLocaleProvider

Provides the set of supported locales. Required when the application supports multiple locales.

java
@Component
public class MyLocaleProvider implements QfLocaleProvider {

    @Override
    public List<QfLocale> getLocales() {
        return List.of(
            QfLocale.of("ko", "한국어", 10),
            QfLocale.of("en", "English", 0)
        );
    }

    @Override
    public int priority() {
        return 100;  // higher value wins; default is 0
    }
}

QfLocaleProvider methods

MethodRequiredDescription
List<QfLocale> getLocales()Returns all locales supported by the application
Optional<QfLocale> guessLocale(QfAbstractExecutionContext context)default: priority-based selectionResolves the best-matching locale from the execution context
int priority()default: 0Selection priority when multiple implementations exist; highest value wins

QfMessageProvider

Resolves message keys into localized text. Required when the application uses message-key-based i18n.

java
@Component
public class MyMessageProvider implements QfMessageProvider {

    @Override
    public String getMessage(String messageKey, String localeCode) {
        return messageSource.getMessage(messageKey, null, Locale.forLanguageTag(localeCode));
    }

    @Override
    public String getMessage(String messageKey, Map<String, String> params, String localeCode) {
        return messageSource.getMessage(messageKey, params.values().toArray(), Locale.forLanguageTag(localeCode));
    }

    @Override
    public int priority() {
        return 100;  // higher value wins; default is 0
    }
}

QfMessageProvider methods

MethodRequiredDescription
String getMessage(String messageKey, String localeCode)Resolves message key for the given locale
String getMessage(String messageKey, Map<String, String> params, String localeCode)Resolves message key and interpolates parameters
int priority()default: 0Selection priority when multiple implementations exist; highest value wins

QfFileStorageProvider

Manages file storage operations (link, cleanup, retrieve). Required when using @QfFile.

java
@Component
public class MyFileStorageProvider implements QfFileStorageProvider {

    @Override
    public void linkFiles(List<Map<String, Object>> files, Object entityId,
                          String tableName, String purpose) {
        // Link temporarily uploaded files to the persisted entity
    }

    @Override
    public void cleanupOrphanFiles(List<Map<String, Object>> files, Object entityId,
                                   String tableName, String purpose) {
        // Remove files no longer referenced by the entity
    }

    @Override
    public List<Map<String, Object>> getFiles(Object entityId, String tableName,
                                              String purpose, int maxFiles) {
        // Return linked files for the entity
        return fileRepository.findByEntityIdAndPurpose(entityId, tableName, purpose, maxFiles);
    }
}

QfFileStorageProvider methods

MethodRequiredDescription
void linkFiles(files, entityId, tableName, purpose)Links uploaded files to the persisted entity after create/update
void cleanupOrphanFiles(files, entityId, tableName, purpose)Removes files no longer referenced by the entity
List<Map<String, Object>> getFiles(entityId, tableName, purpose, maxFiles)Retrieves files linked to the given entity

QfEntityHistoryProvider

Provides paginated entity change history. Required when using @QfEntity(history = true).

java
@Component
public class MyEntityHistoryProvider implements QfEntityHistoryProvider {

    @Override
    public QfPageResultDto<Map<String, Object>> getHistory(
            QfEntityMetadataDoc.Entity entityMetadata,
            QfEntityHistoryRequest request) {
        // Return paginated change history ordered from newest to oldest
        return historyRepository.findByEntityId(
            entityMetadata.fqcn(), request.entityId(), request.toPageable()
        );
    }

    @Override
    public int priority() {
        return 100;  // higher value wins; default is 0
    }
}

QfEntityHistoryProvider methods

MethodRequiredDescription
QfPageResultDto<Map<String, Object>> getHistory(entityMetadata, request)Returns paginated change history (newest to oldest)
int priority()default: 0Selection priority when multiple implementations exist; highest value wins

QfOperationPrivilegeChecker

Enforces operation-level privilege checks before every CRUD operation. Optional; when not configured, all operations are unconditionally allowed.

java
@Component
public class MyPrivilegeChecker implements QfOperationPrivilegeChecker {

    @Override
    public void checkOrThrow(QfEntityMetadataDoc.Entity entityMetadata,
                             Operation operation,
                             QfAbstractExecutionContext context) {
        String privilegeKey = entityMetadata.capabilityKey() + "__" + operation.name().toLowerCase();
        if (!context.getCurrentUser().hasPrivilege(privilegeKey)) {
            throw new QfManagedException("ACCESS_DENIED", "Insufficient privilege: " + privilegeKey);
        }
    }

    @Override
    public int priority() {
        return 100;  // higher value wins; default is 0
    }
}

QfOperationPrivilegeChecker methods

MethodRequiredDescription
void checkOrThrow(entityMetadata, operation, context)Checks privilege and throws QfManagedException if denied; returns normally if granted
int priority()default: 0Selection priority when multiple implementations exist; highest value wins

Operation enum values

ValueDescription
CREATECreate operation
LISTList (read-many) operation
DETAILDetail (read-one) operation
UPDATEUpdate operation
DELETEDelete operation
TREETree query operation
CHECK_UNIQUEUnique constraint check operation
HISTORYHistory query operation
KEY_VALUEKey-value fetch operation
UPDATE_KEY_VALUEKey-value update operation

Direct Data Access in SPI Implementations

SPI implementations that inject Spring Data Repositories, EntityManager, JdbcTemplate, or other direct data access types must annotate those fields with @QfAllowedDirectAccess(reason = "...").

This is required when qf.persistence.access.mode is permissive (default) or strict. Without it, the application will fail to start.


SPI Registration

In a Spring Boot environment, simply add @Component and the SPI is automatically registered.

java
@Component  // Q-Framework auto-discovers this
public class MyUserProvider implements QfUserProvider {
    // ...
}

In non-Spring environments, use the ServiceLoader approach:

META-INF/services/net.softminds.qframework.spi.QfUserProvider
→ com.example.myapp.MyUserProvider

SPI Summary

SPI InterfaceRequired?Description
QfUserProvider✅ RequiredProvides current user information
QfOrganizationProviderConditionalRequired when using the organization model
QfLocaleProviderConditionalRequired when supporting multiple locales
QfMessageProviderConditionalRequired when using message-key-based i18n
QfFileStorageProviderConditionalRequired when using @QfFile
QfEntityHistoryProviderConditionalRequired when using @QfEntity(history = true)
QfOperationPrivilegeCheckerOptionalOperation-level privilege enforcement
QfRuntimeInitializationHookOptionalHook at initialization time
QfInitDataContributorOptionalProvide frontend init data
QfDiagnosticListenerOptionalReceive diagnostic events
QfRuntimeConfigProviderOptionalProvide dynamic configuration

Next Steps

Released under the Apache 2.0 License.