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.
@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
| Method | Required | Description |
|---|---|---|
QfUser getUserById(Object userId) | ✅ | Resolves a user by identifier |
QfUser getCurrentUser(QfRequestContext context) | default: returns null | Resolves the currently authenticated user from request context |
int priority() | default: 0 | Selection priority when multiple implementations exist; highest value wins |
QfOrganizationProvider
Provides organizational hierarchy data. Required when using the organization model.
@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
| Method | Required | Description |
|---|---|---|
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 list | Returns organizations the user is explicitly delegated to manage (cross-org access) |
List<QfOrganization> getAncestors(Object orgId, int maxDepth) | default: in-memory traversal | Returns ancestor organizations up to maxDepth levels above orgId |
List<QfOrganization> getAllUnder(Collection<Object> orgIds) | default: in-memory BFS | Returns all descendant organizations (including seeds) reachable from given roots |
int priority() | default: 0 | Selection priority when multiple implementations exist; highest value wins |
QfRuntimeInitializationHook
Performs additional work at runtime initialization time.
@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.
@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.
| Field | Type | Description |
|---|---|---|
localeRevision | String | Client's current locale list revision |
messageRevision | String | Client's current message revision for the active locale |
setupRevision | String | Client's current setup data revision |
rsaPublicKey | String | Client'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.
@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.).
@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.
@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
| Method | Required | Description |
|---|---|---|
List<QfLocale> getLocales() | ✅ | Returns all locales supported by the application |
Optional<QfLocale> guessLocale(QfAbstractExecutionContext context) | default: priority-based selection | Resolves the best-matching locale from the execution context |
int priority() | default: 0 | Selection priority when multiple implementations exist; highest value wins |
QfMessageProvider
Resolves message keys into localized text. Required when the application uses message-key-based i18n.
@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
| Method | Required | Description |
|---|---|---|
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: 0 | Selection priority when multiple implementations exist; highest value wins |
QfFileStorageProvider
Manages file storage operations (link, cleanup, retrieve). Required when using @QfFile.
@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
| Method | Required | Description |
|---|---|---|
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).
@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
| Method | Required | Description |
|---|---|---|
QfPageResultDto<Map<String, Object>> getHistory(entityMetadata, request) | ✅ | Returns paginated change history (newest to oldest) |
int priority() | default: 0 | Selection 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.
@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
| Method | Required | Description |
|---|---|---|
void checkOrThrow(entityMetadata, operation, context) | ✅ | Checks privilege and throws QfManagedException if denied; returns normally if granted |
int priority() | default: 0 | Selection priority when multiple implementations exist; highest value wins |
Operation enum values
| Value | Description |
|---|---|
CREATE | Create operation |
LIST | List (read-many) operation |
DETAIL | Detail (read-one) operation |
UPDATE | Update operation |
DELETE | Delete operation |
TREE | Tree query operation |
CHECK_UNIQUE | Unique constraint check operation |
HISTORY | History query operation |
KEY_VALUE | Key-value fetch operation |
UPDATE_KEY_VALUE | Key-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.
@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.MyUserProviderSPI Summary
| SPI Interface | Required? | Description |
|---|---|---|
QfUserProvider | ✅ Required | Provides current user information |
QfOrganizationProvider | Conditional | Required when using the organization model |
QfLocaleProvider | Conditional | Required when supporting multiple locales |
QfMessageProvider | Conditional | Required when using message-key-based i18n |
QfFileStorageProvider | Conditional | Required when using @QfFile |
QfEntityHistoryProvider | Conditional | Required when using @QfEntity(history = true) |
QfOperationPrivilegeChecker | Optional | Operation-level privilege enforcement |
QfRuntimeInitializationHook | Optional | Hook at initialization time |
QfInitDataContributor | Optional | Provide frontend init data |
QfDiagnosticListener | Optional | Receive diagnostic events |
QfRuntimeConfigProvider | Optional | Provide dynamic configuration |
Next Steps
- Architecture Overview — Q-Framework internal structure
- Annotation Reference — Complete annotation list