ADR-044: Component Extraction Strategy
Status
Proposed
Date
2025-12-10
Deciders
- Cloudempiere AI Team
Context and Problem Statement
Following ADR-043, we need a detailed strategy for extracting ~131 source files from com.cloudempiere.ai (Java 11, OSGi) and integrating them into idempiere-cli (Java 17+, Quarkus).
Key challenges:
- Different dependency injection - OSGi vs Quarkus CDI
- Different database access - iDempiere PO vs Quarkus/Panache
- Different logging - iDempiere CLogger vs Quarkus/JBoss logging
- Different configuration - OSGi config vs Quarkus application.properties
- iDempiere-specific code - X_* models, MRole, Env context
Decision Drivers
- Minimize rewrite - Preserve logic, adapt integration points
- Clean boundaries - Clear separation of concerns
- Testability - Extracted code must be unit-testable
- Incremental migration - Deliver value at each phase
Decision Outcome
Use a layered extraction strategy with clear transformation rules for each layer.
Extraction Layers
Layer 1: Pure DTOs (No Dependencies)
Extraction: Direct copy with minor cleanup
Source: com.cloudempiere.ai/provider/dto/
Target: org.idempiere.cli/satellite/dto/
Files:
├── AIRequest.java → SatelliteRequest.java (rename for clarity)
├── AIResponse.java → SatelliteResponse.java
├── AIMessage.java → ChatMessage.java
├── AITokenUsage.java → TokenUsage.java
├── AIStreamCallback.java → StreamCallback.java
├── AIFunction.java → ToolFunction.java
├── AIFunctionCall.java → ToolFunctionCall.java
├── AIHealthStatus.java → HealthStatus.java
├── AIRateLimitStatus.java → RateLimitStatus.java
└── AIModelCapabilities.java → ModelCapabilities.java
Transformation Rules:
- Remove
AIprefix (redundant in satellite context) - Add
@RegisterForReflectionfor native image - Convert fields to records where appropriate (Java 17+)
- Add Jackson annotations for JSON serialization
Example Transformation:
// BEFORE (Java 11, OSGi)
public class AITokenUsage {
private int promptTokens;
private int completionTokens;
private int totalTokens;
// getters, setters, builder...
}
// AFTER (Java 17+, Quarkus)
@RegisterForReflection
public record TokenUsage(
int promptTokens,
int completionTokens,
int totalTokens
) {
public static TokenUsage of(int prompt, int completion) {
return new TokenUsage(prompt, completion, prompt + completion);
}
}
Layer 2: Business Logic (Minimal Dependencies)
Extraction: Copy with dependency injection adaptation
Source: com.cloudempiere.ai/guardrails/
Target: org.idempiere.cli/satellite/guardrails/
Files:
├── InputGuard.java → InputGuard.java (adapt logging)
├── OutputGuard.java → OutputGuard.java
├── ExecutionGuard.java → ExecutionGuard.java
└── GuardResult.java → GuardResult.java (direct copy)
Transformation Rules:
| OSGi Pattern | Quarkus Pattern |
|---|---|
private static CLogger log = CLogger.getCLogger(X.class) |
@Inject Logger log or private static final Logger log = Logger.getLogger(X.class) |
Singleton via getInstance() |
@ApplicationScoped |
| Manual instantiation | @Inject |
Env.getCtx() context |
Pass context as parameter |
Example Transformation:
// BEFORE (Java 11, OSGi)
public class InputGuard {
private static CLogger log = CLogger.getCLogger(InputGuard.class);
private static InputGuard instance;
public static InputGuard getInstance() {
if (instance == null) instance = new InputGuard();
return instance;
}
public GuardResult validate(String input, Properties ctx) {
int adClientId = Env.getAD_Client_ID(ctx);
// validation logic...
}
}
// AFTER (Java 17+, Quarkus)
@ApplicationScoped
public class InputGuard {
private static final Logger log = Logger.getLogger(InputGuard.class);
public GuardResult validate(String input, ChatContext ctx) {
int adClientId = ctx.adClientId();
// same validation logic...
}
}
Layer 3: Observability (Metrics & Audit)
Extraction: Adapt to Quarkus observability stack
Source: com.cloudempiere.ai/observability/
Target: org.idempiere.cli/satellite/observability/
Files:
├── AIMetricsListener.java → SatelliteMetricsListener.java
├── CostGuard.java → CostGuard.java
└── UsageMetrics.java → UsageMetrics.java (DTO)
Key Adaptations:
| OSGi/iDempiere | Quarkus |
|---|---|
MAIUsageMetrics.record() |
Micrometer Counter/Gauge |
MAIBudget table lookup |
Configuration or external service |
CCache |
Quarkus Cache extension |
| Custom metrics format | Prometheus format |
Example: Metrics Listener Transformation
// BEFORE (Java 11)
public class AIMetricsListener implements ChatModelListener {
@Override
public void onResponse(ChatModelResponseContext ctx) {
TokenUsage usage = ctx.response().tokenUsage();
// Record to iDempiere database
MAIUsageMetrics.record(
Env.getCtx(),
usage.inputTokenCount(),
usage.outputTokenCount(),
calculateCost(ctx.request().model(), usage)
);
}
}
// AFTER (Java 17+, Quarkus)
@ApplicationScoped
public class SatelliteMetricsListener implements ChatModelListener {
@Inject
MeterRegistry registry;
@Inject
UsageMetricsService metricsService; // New service
@Override
public void onResponse(ChatModelResponseContext ctx) {
TokenUsage usage = ctx.response().tokenUsage();
String model = ctx.request().model();
// Prometheus metrics
registry.counter("satellite.tokens.input", "model", model)
.increment(usage.inputTokenCount());
registry.counter("satellite.tokens.output", "model", model)
.increment(usage.outputTokenCount());
registry.counter("satellite.cost.usd", "model", model)
.increment(calculateCost(model, usage));
// Persist to database (optional, async)
metricsService.recordAsync(ctx, usage);
}
}
Layer 4: Database Access
Extraction: Replace iDempiere PO with Quarkus patterns
Source: com.cloudempiere.ai/model/ (M* classes only, not X_*)
Target: org.idempiere.cli/satellite/model/
Approach: Create new Quarkus entities, migrate business logic
iDempiere Model → Quarkus Entity Mapping:
| iDempiere (X_/M*) | Quarkus (Panache/JPA) |
|---|---|
X_AIG_Provider |
AigProvider entity |
MAIProvider |
AigProviderRepository + service |
X_AIG_UsageMetrics |
AigUsageMetrics entity |
MAIUsageMetrics |
AigUsageMetricsRepository |
Example Entity Transformation:
// BEFORE: iDempiere X_ model (generated)
public class X_AIG_Provider extends PO {
public static final String COLUMNNAME_AIG_Provider_ID = "AIG_Provider_ID";
public static final String COLUMNNAME_AIGProviderType = "AIGProviderType";
// ... 50+ columns
}
// BEFORE: iDempiere M* model (business logic)
public class MAIProvider extends X_AIG_Provider {
private static CCache<Integer, MAIProvider> s_cache =
new CCache<>("AIG_Provider", 10, 60);
public static MAIProvider get(Properties ctx, int id) {
MAIProvider provider = s_cache.get(id);
if (provider == null) {
provider = new MAIProvider(ctx, id, null);
s_cache.put(id, provider);
}
return provider;
}
}
// AFTER: Quarkus entity
@Entity
@Table(name = "aig_provider")
@Cacheable
public class AigProvider extends PanacheEntity {
@Column(name = "aig_provider_id")
public Long aigProviderId;
@Column(name = "aigprovidertype")
public String providerType;
@Column(name = "name")
public String name;
@Column(name = "endpoint")
public String endpoint;
@Column(name = "apikey")
public String apiKey; // Note: Consider encryption
@Column(name = "modelname")
public String modelName;
@Column(name = "isactive")
public boolean active;
@Column(name = "isdefault")
public boolean isDefault;
@Column(name = "ad_client_id")
public int adClientId;
}
// AFTER: Quarkus repository
@ApplicationScoped
public class AigProviderRepository implements PanacheRepository<AigProvider> {
@CacheResult(cacheName = "providers")
public AigProvider findById(Long id) {
return find("aigProviderId", id).firstResult();
}
public AigProvider findDefault(int adClientId) {
return find("adClientId = ?1 and isDefault = true and active = true",
adClientId).firstResult();
}
public List<AigProvider> findByType(int adClientId, String type) {
return find("adClientId = ?1 and providerType = ?2 and active = true",
adClientId, type).list();
}
}
Layer 5: Context Management
Extraction: Create new iDempiere-agnostic context system
Source: com.cloudempiere.ai/routing/, context/
Target: org.idempiere.cli/satellite/context/
New Components:
├── ChatContext.java → Request context (from headers)
├── ConversationContext.java → Chat memory context
├── ContextManager.java → TTL-based context cache
└── ContextExtractor.java → Extract from HTTP headers
ChatContext (New):
@RegisterForReflection
public record ChatContext(
// Security context (from X-iDempiere-* headers)
int adClientId,
int adOrgId,
int adUserId,
int adRoleId,
String sessionId,
// Optional UI context
Integer adWindowId,
Integer adTabId,
Integer recordId,
String tableName,
// Locale
String language,
// Conversation
String conversationId
) {
public static ChatContext fromHeaders(HttpHeaders headers) {
return new ChatContext(
parseIntHeader(headers, "X-iDempiere-Client-ID", 0),
parseIntHeader(headers, "X-iDempiere-Org-ID", 0),
parseIntHeader(headers, "X-iDempiere-User-ID", 0),
parseIntHeader(headers, "X-iDempiere-Role-ID", 0),
headers.getHeaderString("X-iDempiere-Session-ID"),
parseOptionalInt(headers, "X-iDempiere-Window-ID"),
parseOptionalInt(headers, "X-iDempiere-Tab-ID"),
parseOptionalInt(headers, "X-iDempiere-Record-ID"),
headers.getHeaderString("X-iDempiere-Table-Name"),
headers.getHeaderString("X-iDempiere-Language"),
headers.getHeaderString("X-Conversation-ID")
);
}
}
Layer 6: Tool Framework
Extraction: Adapt to Quarkus CDI and LangChain4j @Tool
Source: com.cloudempiere.ai/tool/
Target: org.idempiere.cli/satellite/tool/
Approach:
- Keep ITool interface for custom tools
- Add @Tool annotation support for LangChain4j integration
- Use CDI for registry
Dual Registration Pattern:
// Custom tool interface (for complex tools)
public interface ISatelliteTool {
String getName();
String getDescription();
ToolResult execute(Map<String, Object> params, ChatContext ctx);
Set<ToolPermission> getRequiredPermissions();
}
// LangChain4j tool (for simple tools)
@ApplicationScoped
public class DatabaseTools {
@Inject
SecureQueryExecutor queryExecutor;
@Tool("Execute a read-only SQL query against iDempiere database")
public String executeQuery(
@P("SQL SELECT query") String sql,
@P("Maximum rows to return") int limit) {
return queryExecutor.execute(sql, limit).toJson();
}
}
// Registry manages both
@ApplicationScoped
public class ToolRegistry {
@Inject
Instance<ISatelliteTool> customTools;
@Inject
Instance<Object> langchainTools; // Classes with @Tool methods
public List<ToolSpecification> getAllTools() {
// Merge both registrations
}
}
Layer 7: REST API (New Development)
New components for OpenAI-compatible API:
Target: org.idempiere.cli/satellite/api/
New Files:
├── SatelliteApiResource.java → Main REST resource
├── dto/
│ ├── ChatCompletionRequest.java → OpenAI-compatible request
│ ├── ChatCompletionResponse.java → OpenAI-compatible response
│ ├── ChatCompletionChoice.java → Response choice
│ ├── ChatCompletionUsage.java → Token usage
│ ├── EmbeddingRequest.java → Embedding request
│ ├── EmbeddingResponse.java → Embedding response
│ └── ErrorResponse.java → Error format
├── ModelRouter.java → Route model → provider
└── StreamingResponseWriter.java → SSE streaming
Dependency Transformation Reference
Logging
| Before (iDempiere) | After (Quarkus) |
|---|---|
CLogger.getCLogger(X.class) |
Logger.getLogger(X.class) |
log.info(msg) |
log.info(msg) (same) |
log.warning(msg) |
log.warn(msg) |
log.severe(msg) |
log.error(msg) |
log.fine(msg) |
log.debug(msg) |
log.finer(msg) |
log.trace(msg) |
Caching
| Before (iDempiere) | After (Quarkus) |
|---|---|
CCache<K,V> |
@CacheResult, @CacheInvalidate |
cache.put(k, v) |
Automatic via annotation |
cache.get(k) |
Automatic via annotation |
| Manual TTL in constructor | quarkus.cache.caffeine.X.expire-after-write |
Configuration
| Before (iDempiere) | After (Quarkus) |
|---|---|
MSysConfig.getValue() |
@ConfigProperty |
Env.getCtx().getProperty() |
@ConfigProperty or Context param |
| Hardcoded values | application.properties |
Database
| Before (iDempiere) | After (Quarkus) |
|---|---|
new Query(ctx, table, where, trx) |
Panache find() methods |
DB.executeUpdate() |
Panache persist(), update() |
ResultSet processing |
Panache entity mapping |
PO.get_ID() |
entity.id |
Threading
| Before (iDempiere) | After (Quarkus) |
|---|---|
CompletableFuture.supplyAsync() |
@Blocking / @NonBlocking |
| Manual thread pools | Quarkus managed executors |
synchronized blocks |
CDI @Lock or Mutiny |
Files NOT to Extract
These files remain in the OSGi plugin:
| File/Package | Reason |
|---|---|
X_*.java |
Generated from iDempiere schema |
Activator.java |
OSGi lifecycle |
component/AIChatWidget.java |
ZK UI specific |
component/AIChatStreamingMessage.java |
ZK UI specific |
| OSGI-INF/*.xml | OSGi service declarations |
*.zul files |
ZK templates |
Testing Strategy
Unit Tests (Extracted Code)
@QuarkusTest
class InputGuardTest {
@Inject
InputGuard guard;
@Test
void shouldDetectPII() {
var result = guard.validate("My SSN is 123-45-6789", testContext());
assertThat(result.status()).isEqualTo(GuardStatus.MASK);
}
@Test
void shouldPassCleanInput() {
var result = guard.validate("Show me open orders", testContext());
assertThat(result.status()).isEqualTo(GuardStatus.PASS);
}
}
Integration Tests (API Layer)
@QuarkusTest
class SatelliteApiResourceTest {
@Test
void shouldHandleChatCompletion() {
given()
.contentType(ContentType.JSON)
.header("Authorization", "Bearer test-token")
.header("X-iDempiere-Client-ID", "1000000")
.body("""
{
"model": "claude-sonnet-4",
"messages": [{"role": "user", "content": "Hello"}]
}
""")
.when()
.post("/v1/chat/completions")
.then()
.statusCode(200)
.body("choices[0].message.content", notNullValue());
}
}
Migration Checklist
Per-File Checklist
- [ ] Copy file to target location
- [ ] Update package declaration
- [ ] Replace
CLoggerwithLogger - [ ] Replace
Env.getCtx()with context parameter - [ ] Replace singletons with
@ApplicationScoped - [ ] Replace
CCachewith Quarkus Cache - [ ] Add
@RegisterForReflectionif needed - [ ] Update imports
- [ ] Write unit test
- [ ] Verify compilation
Per-Phase Checklist
- [ ] All files migrated
- [ ] All tests passing
- [ ] Integration test added
- [ ] Documentation updated
- [ ] CHANGELOG entry added
Related ADRs
- ADR-043: Satellite Service Architecture Evolution
- ADR-045: idempiere-cli Integration Architecture
ADR-044 | Version 1.0 | 2025-12-10 Status: Proposed