Series MapLesson 30 / 60
Build CoreOrdered learning track

Learn Enterprise Cpq Oms Glassfish Camunda8 Part 030 Product Configuration Engine From Scratch

12 min read2291 words
PrevNext
Lesson 3060 lesson track1233 Build Core

title: Build From Scratch: Enterprise Java Microservices CPQ & Order Management Platform - Part 030 description: Building a deterministic, explainable, production-oriented product configuration engine from scratch for enterprise CPQ using catalog graph, rules, validation, snapshots, and Java service boundaries. series: learn-enterprise-cpq-oms-glassfish-camunda8 seriesTitle: Build From Scratch: Enterprise Java Microservices CPQ & Order Management Platform order: 30 partTitle: Product Configuration Engine From Scratch tags:

  • java
  • microservices
  • cpq
  • oms
  • product-configuration
  • configuration-engine
  • rules
  • catalog
  • postgresql
  • mybatis
  • redis
  • kafka
  • schema-first date: 2026-07-02

Part 030 — Product Configuration Engine From Scratch

Sekarang kita mulai masuk ke blok CPQ engine implementation.

Kita sudah punya fondasi:

  • product catalog domain model,
  • commercial vs technical catalog,
  • quote model,
  • pricing model,
  • invariant dan state machine,
  • API/schema-first strategy,
  • Java/JAX-RS runtime,
  • PostgreSQL/MyBatis persistence,
  • migration discipline.

Sekarang kita membangun engine pertama yang membuat CPQ benar-benar menjadi CPQ:

Product Configuration Engine.

Tujuannya bukan membuat rule engine generik yang terlalu abstrak. Tujuannya membangun engine konfigurasi produk yang deterministic, explainable, testable, versioned, dan cukup kuat untuk enterprise CPQ.


1. Apa Masalah yang Diselesaikan Configuration Engine?

Configuration engine menjawab pertanyaan:

“Dengan product offering ini, customer context ini, installed base ini, dan pilihan user ini, konfigurasi produk apa yang valid, apa yang invalid, apa yang masih kurang, dan kenapa?”

Contoh produk:

Fiber Internet 1Gbps Bundle
  - Access Plan: 1Gbps
  - Router: Standard WiFi Router / WiFi 6 Router / Customer Owned Router
  - Static IP: None / 1 IP / 5 IP
  - Installation: Standard / Express
  - Contract Term: 12 months / 24 months
  - Add-on: Streaming Pack / Security Pack / Gaming Pack

Rule:

WiFi 6 Router requires Contract Term >= 12 months.
Express Installation not available in remote area.
5 Static IP requires business customer.
Gaming Pack incompatible with Security Pack in legacy region.
Customer Owned Router requires waiver approval.

Tanpa engine, logic akan bocor ke mana-mana:

JAX-RS resource
QuoteService
PricingService
UI frontend
SQL query
Camunda worker
Integration adapter

Itu cepat di awal, tetapi menghancurkan sistem ketika rule bertambah.

Engine harus menjadi satu tempat yang menjawab:

valid / invalid / incomplete / requires approval / not eligible

beserta alasan yang bisa dijelaskan.


2. Engine Ini Bukan Apa?

Kita perlu membatasi scope.

Engine ini bukan:

  • full SAT solver,
  • generic business rule management system,
  • AI recommendation engine,
  • pricing engine,
  • order decomposition engine,
  • approval engine,
  • UI wizard engine,
  • catalog authoring tool.

Ia adalah domain engine untuk:

product option selection
characteristic value validation
compatibility check
dependency check
eligibility check
defaulting
cardinality enforcement
configuration explanation
configuration snapshot generation

Rule pricing tetap di pricing engine. Rule fulfillment tetap di decomposition/OMS. Rule approval tetap di approval engine.

Boundary ini penting agar configuration engine tidak menjadi “god engine”.


3. Input dan Output Engine

Input

public record ConfigureProductCommand(
    TenantId tenantId,
    ProductOfferingId offeringId,
    CatalogVersion catalogVersion,
    CustomerContext customerContext,
    InstalledBaseContext installedBaseContext,
    List<ConfigurationSelection> selections,
    ConfigurationMode mode,
    RequestContext requestContext
) {}

Input utama:

InputFungsi
tenantIdisolasi catalog/reference data
offeringIdproduk yang sedang dikonfigurasi
catalogVersionversi catalog agar deterministic
customerContextsegment, location, account type, risk class
installedBaseContextasset/subscription existing untuk modify/disconnect/add-on
selectionspilihan eksplisit user/system
modedraft, validate, submit, reprice, conversion
requestContextactor, channel, correlation, authorization evidence

Output

public record ConfigurationResult(
    ConfigurationStatus status,
    ProductOfferingId offeringId,
    CatalogVersion catalogVersion,
    List<ResolvedSelection> selections,
    List<AvailableOption> availableOptions,
    List<ConfigurationIssue> issues,
    List<ConfigurationApprovalSignal> approvalSignals,
    ConfigurationExplanation explanation,
    ConfigurationHash configurationHash
) {}

Status minimal:

VALID
INCOMPLETE
INVALID
REQUIRES_APPROVAL
NOT_ELIGIBLE
CATALOG_VERSION_NOT_FOUND

Output engine bukan hanya boolean.

Boolean true/false tidak cukup untuk CPQ enterprise. Sales, approval, support, audit, dan integration membutuhkan alasan.


4. Model Konseptual

Product configuration adalah graph problem.

Node adalah item/characteristic/option. Edge adalah relationship/rule.

Engine tugasnya membaca graph ini, lalu mengevaluasi selection terhadap constraint.


5. Data Structure Internal

Jangan langsung mengevaluasi dari row database mentah. Buat model internal yang jelas.

public final class ConfigurationModel {
    private final ProductOfferingId offeringId;
    private final CatalogVersion catalogVersion;
    private final Map<NodeId, ConfigNode> nodes;
    private final List<ConfigConstraint> constraints;
    private final List<DefaultRule> defaultRules;
    private final List<EligibilityRule> eligibilityRules;
}

Node:

public sealed interface ConfigNode permits ProductNode, OptionGroupNode, OptionNode, CharacteristicNode {
    NodeId id();
    String code();
    String label();
    NodeLifecycle lifecycle();
}

Option group:

public record OptionGroupNode(
    NodeId id,
    String code,
    String label,
    Cardinality cardinality,
    List<NodeId> optionIds
) implements ConfigNode {}

Cardinality:

public record Cardinality(int min, int max) {
    public boolean allows(int selectedCount) {
        return selectedCount >= min && selectedCount <= max;
    }
}

Selection:

public record ConfigurationSelection(
    ConfigurationPath path,
    String value,
    SelectionSource source
) {}

Selection source:

USER
DEFAULT_RULE
SYSTEM
INSTALLED_BASE
MIGRATION

Kenapa selection source penting?

Karena conflict dari pilihan user berbeda dengan conflict dari default rule. Pilihan user biasanya butuh error/explanation. Default rule bisa dicabut otomatis jika tidak compatible.


6. Constraint Taxonomy

Kita gunakan taxonomy yang cukup kaya tetapi masih manageable.

ConstraintMaknaContoh
Requiredpilihan wajib adaRouter wajib dipilih
Cardinalityjumlah pilihan min/maxAdd-on max 3
Mutually Exclusivedua pilihan tidak boleh bersamaanGaming Pack vs Security Pack
Dependencypilihan A butuh BWiFi 6 Router butuh 12 months term
Compatibilitypilihan cocok/tidak cocok dengan context/produk lainStatic IP 5 hanya business customer
Eligibilityproduct/option boleh dijual ke customer tertentuExpress install hanya serviceable area
Allowed Valuevalue characteristic harus validbandwidth in allowed set
Rangenumeric value dalam rangequantity 1..10
Lifecycleoption masih active/effectiveretired router tidak boleh untuk quote baru
Action-Basedrule berbeda untuk ADD/MODIFY/DISCONNECTdisconnect tidak butuh installation
Approval Signalvalid tetapi butuh approvalCustomer owned router butuh waiver approval

Perhatikan: Approval Signal bukan invalid. Ia valid secara konfigurasi tetapi membutuhkan proses approval.


7. Evaluation Pipeline

Engine harus deterministic. Jangan evaluasi rule dalam urutan acak.

Pipeline:

Urutan ini bukan satu-satunya benar, tetapi harus eksplisit dan diuji.


8. Normalisasi Selection

User/API bisa mengirim selection yang tidak rapi.

Contoh:

{
  "selections": [
    { "path": "Router", "value": "wifi6 router" },
    { "path": "contractTerm", "value": "12" }
  ]
}

Engine harus normalize menjadi canonical code:

{
  "selections": [
    { "path": "router", "value": "WIFI6_ROUTER" },
    { "path": "contract_term", "value": "TERM_12_MONTHS" }
  ]
}

Tetapi hati-hati: normalisasi tidak boleh menebak terlalu agresif. Jika ambiguous, return issue.

public final class SelectionNormalizer {
    public NormalizedSelectionSet normalize(
        ConfigurationModel model,
        List<ConfigurationSelection> rawSelections
    ) {
        // 1. canonicalize path
        // 2. canonicalize value
        // 3. detect duplicate path
        // 4. detect unknown path/value
        // 5. preserve source
        throw new UnsupportedOperationException("implementation later");
    }
}

Issue contoh:

{
  "code": "UNKNOWN_CONFIGURATION_VALUE",
  "severity": "ERROR",
  "path": "router",
  "message": "Value 'wifi7-router' is not available for option group 'router'."
}

9. Defaulting Rule

Defaulting rule mengisi pilihan yang belum diisi.

Contoh:

If customer segment = RESIDENTIAL and router not selected,
default router = STANDARD_ROUTER.

Model:

public record DefaultRule(
    RuleId id,
    int priority,
    PredicateExpression condition,
    ConfigurationPath targetPath,
    String defaultValue,
    boolean overridable
) {}

Default rule harus:

  • deterministic,
  • priority-based,
  • explainable,
  • tidak override user selection kecuali eksplisit,
  • diuji conflict-nya.

Jika dua default rule mengisi path sama:

Rule A -> router = STANDARD_ROUTER
Rule B -> router = WIFI6_ROUTER

Engine tidak boleh diam-diam memilih random. Harus ada priority atau issue:

DEFAULT_RULE_CONFLICT

10. Eligibility Rule

Eligibility rule menjawab:

apakah product/option boleh ditawarkan untuk customer/context ini?

Contoh:

Express Installation eligible only if location.serviceable = true and region != REMOTE.
5 Static IP eligible only for BUSINESS customer.

Context object:

public record CustomerContext(
    CustomerId customerId,
    CustomerSegment segment,
    AccountType accountType,
    LocationContext location,
    RiskClass riskClass,
    Map<String, String> attributes
) {}

Rule model sederhana:

public record EligibilityRule(
    RuleId id,
    NodeId targetNodeId,
    PredicateExpression condition,
    String failureCode,
    String failureMessage
) {}

Pada fase awal, predicate expression bisa kita buat terbatas:

EQ(field, value)
NE(field, value)
IN(field, values)
NOT_IN(field, values)
GTE(field, number)
LTE(field, number)
AND(...)
OR(...)
NOT(...)

Jangan langsung membuat expression language terlalu bebas. Kebebasan berlebihan membuat rule sulit diaudit.


11. Dependency Constraint

Dependency constraint:

Jika A dipilih, B harus ada.

Contoh:

WIFI6_ROUTER requires CONTRACT_TERM in {TERM_12_MONTHS, TERM_24_MONTHS}

Model:

public record DependencyConstraint(
    RuleId id,
    ConfigurationPath whenPath,
    String whenValue,
    ConfigurationPath requiredPath,
    Set<String> allowedRequiredValues,
    Severity severity
) implements ConfigConstraint {}

Evaluation:

if (selectionSet.has(whenPath, whenValue)) {
    if (!selectionSet.hasAny(requiredPath, allowedRequiredValues)) {
        issues.add(ConfigurationIssue.error(
            "MISSING_REQUIRED_DEPENDENCY",
            requiredPath,
            "WIFI6_ROUTER requires contract term 12 or 24 months."
        ));
    }
}

Dependency bisa menghasilkan:

  • error,
  • warning,
  • automatic suggestion,
  • approval signal.

Untuk awal, buat error dulu. Suggestion bisa menyusul.


12. Mutually Exclusive Constraint

Constraint:

A dan B tidak boleh dipilih bersamaan.

Model:

public record MutuallyExclusiveConstraint(
    RuleId id,
    ConfigurationPath leftPath,
    String leftValue,
    ConfigurationPath rightPath,
    String rightValue,
    Severity severity
) implements ConfigConstraint {}

Evaluation:

if (selectionSet.has(leftPath, leftValue) && selectionSet.has(rightPath, rightValue)) {
    issues.add(ConfigurationIssue.error(
        "MUTUALLY_EXCLUSIVE_SELECTION",
        leftPath,
        "Gaming Pack cannot be combined with Security Pack."
    ));
}

Kalau keduanya berasal dari default rule, engine bisa memilih rule priority. Kalau salah satu dari user, jangan hapus diam-diam. Laporkan.


13. Cardinality Constraint

Option group biasanya punya min/max.

Contoh:

Router: min 1, max 1
Add-ons: min 0, max 3
Static IP: min 1, max 1

Evaluation:

int selected = selectionSet.countSelected(group.path());
if (!group.cardinality().allows(selected)) {
    issues.add(ConfigurationIssue.error(
        "CARDINALITY_VIOLATION",
        group.path(),
        "Option group 'router' requires exactly 1 selection."
    ));
}

Cardinality issue penting untuk UI wizard karena bisa memberi tahu step mana yang incomplete.


14. Allowed Value dan Range

Characteristic bisa berupa enumerated value atau numeric range.

Contoh:

contract_term_months in {12, 24, 36}
number_of_static_ip between 0 and 5
bandwidth_mbps in {100, 500, 1000}

Model:

public sealed interface ValueDomain permits EnumValueDomain, NumericRangeDomain, TextPatternDomain {}

public record EnumValueDomain(Set<String> allowedValues) implements ValueDomain {}
public record NumericRangeDomain(BigDecimal min, BigDecimal max, BigDecimal step) implements ValueDomain {}
public record TextPatternDomain(String regex) implements ValueDomain {}

Jangan biarkan value menjadi free-form string tanpa domain kecuali benar-benar perlu.


15. Approval Signal

Beberapa konfigurasi valid tetapi butuh approval.

Contoh:

Customer Owned Router requires technical waiver approval.
Contract term 36 months for residential customer requires sales manager approval.

Model:

public record ConfigurationApprovalSignal(
    String code,
    ApprovalPolicyCode policyCode,
    ConfigurationPath path,
    String selectedValue,
    String reason
) {}

Evaluation:

if (selectionSet.has("router", "CUSTOMER_OWNED_ROUTER")) {
    approvalSignals.add(new ConfigurationApprovalSignal(
        "CUSTOMER_OWNED_ROUTER_WAIVER",
        new ApprovalPolicyCode("TECHNICAL_WAIVER"),
        ConfigurationPath.of("router"),
        "CUSTOMER_OWNED_ROUTER",
        "Customer owned router requires technical waiver approval."
    ));
}

Status result:

VALID + approvalSignals not empty => REQUIRES_APPROVAL

Jangan mencampur approval signal dengan invalid configuration.


16. Fixpoint Evaluation

Beberapa rule saling mempengaruhi.

Contoh:

Default router depends on customer segment.
Default contract term depends on router.
Eligibility of installation depends on location.
Available add-on depends on access plan and region.

Kita bisa memakai iterative pass sampai stabil.

public ConfigurationResult configure(ConfigureProductCommand command) {
    ConfigurationModel model = modelLoader.load(
        command.tenantId(),
        command.offeringId(),
        command.catalogVersion()
    );

    EvaluationState state = EvaluationState.from(command);

    for (int i = 0; i < MAX_PASSES; i++) {
        EvaluationState before = state.snapshot();

        state = normalizer.apply(model, state);
        state = installedBaseResolver.apply(model, state);
        state = defaultRuleEvaluator.apply(model, state);
        state = eligibilityEvaluator.apply(model, state);
        state = constraintEvaluator.apply(model, state);
        state = approvalSignalEvaluator.apply(model, state);

        if (state.semanticallyEquals(before)) {
            break;
        }

        if (i == MAX_PASSES - 1) {
            state.addIssue(ConfigurationIssue.error(
                "CONFIGURATION_EVALUATION_DID_NOT_CONVERGE",
                null,
                "Configuration evaluation did not converge. Check cyclic rules."
            ));
        }
    }

    return resultAssembler.assemble(model, state);
}

MAX_PASSES harus kecil dan diamati. Jika butuh banyak pass, rule model kemungkinan buruk.


17. Cycle Detection

Rule cycle berbahaya.

Contoh:

A requires B
B excludes A

atau:

Default A if B
Default B if A not selected

Catalog publish process harus mendeteksi cycle sebelum catalog aktif.

Graph dependency:

Cycle tidak selalu salah, tetapi harus diketahui. Untuk engine awal, lebih aman menolak cycle pada dependency/default graph kecuali rule type memang dirancang untuk itu.

Pseudo detection:

public final class RuleGraphValidator {
    public List<CatalogIssue> validate(ConfigurationModel model) {
        DirectedGraph<RuleNode> graph = buildRuleGraph(model);
        return graph.findCycles().stream()
            .map(cycle -> CatalogIssue.error(
                "CONFIGURATION_RULE_CYCLE",
                cycle.describe()
            ))
            .toList();
    }
}

18. Available Options

UI butuh tahu option mana yang tersedia, tidak tersedia, dan alasannya.

Output:

{
  "path": "static_ip",
  "options": [
    {
      "value": "NO_STATIC_IP",
      "available": true
    },
    {
      "value": "ONE_STATIC_IP",
      "available": true
    },
    {
      "value": "FIVE_STATIC_IP",
      "available": false,
      "reasonCode": "BUSINESS_CUSTOMER_REQUIRED",
      "reason": "5 Static IP is only available for business customers."
    }
  ]
}

Ini lebih baik daripada hanya menyembunyikan option.

Kenapa?

  • Sales bisa menjelaskan ke customer.
  • Support bisa debug kenapa option tidak muncul.
  • Audit bisa membuktikan product tidak eligible.
  • UI bisa memberi suggestion.

19. Explanation Model

Configuration engine production harus explainable.

Minimal explanation:

public record ConfigurationExplanation(
    List<ExplanationEntry> entries
) {}

public record ExplanationEntry(
    String ruleId,
    String ruleType,
    ConfigurationPath path,
    String selectedValue,
    ExplanationOutcome outcome,
    String message
) {}

Contoh:

{
  "ruleId": "RULE_STATIC_IP_BUSINESS_ONLY",
  "ruleType": "ELIGIBILITY",
  "path": "static_ip",
  "selectedValue": "FIVE_STATIC_IP",
  "outcome": "FAILED",
  "message": "5 Static IP requires customer segment BUSINESS. Current segment is RESIDENTIAL."
}

Explanation tidak harus selalu dikirim penuh ke UI. Bisa disimpan di snapshot atau audit trail, lalu exposure-nya diatur.


20. Configuration Hash

Kita butuh hash untuk mendeteksi perubahan konfigurasi.

Hash harus dihitung dari canonical representation:

{
  "catalogVersion": "cat-2026-07-01",
  "offeringId": "fiber-1gbps-bundle",
  "selections": [
    { "path": "access_plan", "value": "FIBER_1GBPS" },
    { "path": "router", "value": "WIFI6_ROUTER" },
    { "path": "contract_term", "value": "TERM_12_MONTHS" }
  ]
}

Canonical rules:

  • sort by path,
  • normalize values,
  • exclude volatile fields,
  • include catalog version,
  • include relevant installed base version for modify orders,
  • include rule set version if separated from catalog version.

Hash berguna untuk:

  • stale quote detection,
  • reprice trigger,
  • duplicate configuration detection,
  • quote snapshot integrity,
  • audit comparison,
  • cache key.

21. Configuration Snapshot

Saat quote item dikonfigurasi, simpan snapshot.

{
  "schemaVersion": "configuration-snapshot.v1",
  "catalogVersion": "cat-2026-07-01",
  "offeringId": "fiber-1gbps-bundle",
  "status": "REQUIRES_APPROVAL",
  "configurationHash": "sha256:...",
  "selections": [
    {
      "path": "router",
      "value": "CUSTOMER_OWNED_ROUTER",
      "source": "USER"
    }
  ],
  "issues": [],
  "approvalSignals": [
    {
      "code": "CUSTOMER_OWNED_ROUTER_WAIVER",
      "policyCode": "TECHNICAL_WAIVER"
    }
  ]
}

Snapshot bukan hanya cache. Ia adalah bukti konfigurasi saat quote dibuat.

Jangan merekonstruksi quote lama dari catalog terbaru.


22. PostgreSQL Tables

Model persistence minimal:

CREATE TABLE product_configuration_session (
  id uuid PRIMARY KEY,
  tenant_id uuid NOT NULL,
  customer_id uuid,
  offering_id uuid NOT NULL,
  catalog_version text NOT NULL,
  status text NOT NULL,
  configuration_hash text,
  selections jsonb NOT NULL,
  issues jsonb NOT NULL,
  approval_signals jsonb NOT NULL,
  explanation jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  created_by text NOT NULL,
  row_version bigint NOT NULL DEFAULT 0
);

CREATE INDEX idx_configuration_session_tenant_customer
ON product_configuration_session (tenant_id, customer_id, updated_at DESC);

Untuk quote item snapshot:

CREATE TABLE quote_item_configuration_snapshot (
  quote_item_id uuid PRIMARY KEY,
  tenant_id uuid NOT NULL,
  catalog_version text NOT NULL,
  offering_id uuid NOT NULL,
  configuration_hash text NOT NULL,
  snapshot jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

Session bisa berubah. Snapshot quote harus immutable setelah quote submit/approval boundary.


23. MyBatis Mapper Direction

Mapper untuk session:

public interface ProductConfigurationSessionMapper {
    ProductConfigurationSessionRow findById(
        @Param("tenantId") UUID tenantId,
        @Param("id") UUID id
    );

    int insert(ProductConfigurationSessionRow row);

    int updateResult(
        @Param("tenantId") UUID tenantId,
        @Param("id") UUID id,
        @Param("status") String status,
        @Param("configurationHash") String configurationHash,
        @Param("selections") String selectionsJson,
        @Param("issues") String issuesJson,
        @Param("approvalSignals") String approvalSignalsJson,
        @Param("explanation") String explanationJson,
        @Param("expectedRowVersion") long expectedRowVersion
    );
}

XML update dengan optimistic lock:

<update id="updateResult">
  UPDATE product_configuration_session
  SET status = #{status},
      configuration_hash = #{configurationHash},
      selections = #{selections}::jsonb,
      issues = #{issues}::jsonb,
      approval_signals = #{approvalSignals}::jsonb,
      explanation = #{explanation}::jsonb,
      updated_at = now(),
      row_version = row_version + 1
  WHERE tenant_id = #{tenantId}
    AND id = #{id}
    AND row_version = #{expectedRowVersion}
</update>

Jika update count 0, return optimistic lock conflict.


24. API Shape

Endpoint utama:

POST /api/v1/configurations/evaluate

Request:

{
  "offeringId": "fiber-1gbps-bundle",
  "catalogVersion": "cat-2026-07-01",
  "customerContext": {
    "customerId": "cust-123",
    "segment": "RESIDENTIAL",
    "location": {
      "region": "JAKARTA",
      "serviceable": true
    }
  },
  "selections": [
    { "path": "router", "value": "WIFI6_ROUTER" },
    { "path": "contract_term", "value": "TERM_12_MONTHS" }
  ]
}

Response:

{
  "status": "VALID",
  "catalogVersion": "cat-2026-07-01",
  "offeringId": "fiber-1gbps-bundle",
  "configurationHash": "sha256:abc123",
  "selections": [
    { "path": "router", "value": "WIFI6_ROUTER", "source": "USER" },
    { "path": "contract_term", "value": "TERM_12_MONTHS", "source": "USER" }
  ],
  "issues": [],
  "approvalSignals": [],
  "links": {
    "price": "/api/v1/prices/evaluate"
  }
}

Jangan expose internal rule engine classes sebagai API contract.


25. Service Boundary

Structure:

configuration-domain/
  ConfigurationModel
  ConfigNode
  ConfigConstraint
  EvaluationState
  ConfigurationResult

configuration-application/
  ConfigureProductUseCase
  ConfigurationModelLoader
  ConfigurationResultAssembler

configuration-infrastructure/
  PostgreSqlConfigurationSessionRepository
  CatalogConfigurationModelRepository
  RedisConfigurationModelCache

configuration-api/
  ConfigurationResource
  ConfigurationRequestDto
  ConfigurationResponseDto

Dependency direction:

Domain tidak bergantung pada JAX-RS, MyBatis, Redis, Kafka, atau PostgreSQL.


26. Engine Class Skeleton

public final class ProductConfigurationEngine {
    private final SelectionNormalizer normalizer;
    private final InstalledBaseSelectionResolver installedBaseResolver;
    private final DefaultRuleEvaluator defaultRuleEvaluator;
    private final EligibilityEvaluator eligibilityEvaluator;
    private final ConstraintEvaluator constraintEvaluator;
    private final ApprovalSignalEvaluator approvalSignalEvaluator;
    private final AvailableOptionCalculator availableOptionCalculator;
    private final ConfigurationExplanationBuilder explanationBuilder;
    private final ConfigurationHashCalculator hashCalculator;

    public ConfigurationResult evaluate(
        ConfigurationModel model,
        ConfigureProductCommand command
    ) {
        EvaluationState state = EvaluationState.initial(command);

        state = normalizer.evaluate(model, state);
        state = installedBaseResolver.evaluate(model, state);
        state = defaultRuleEvaluator.evaluate(model, state);
        state = eligibilityEvaluator.evaluate(model, state);
        state = constraintEvaluator.evaluate(model, state);
        state = approvalSignalEvaluator.evaluate(model, state);

        List<AvailableOption> availableOptions =
            availableOptionCalculator.calculate(model, state);

        ConfigurationExplanation explanation =
            explanationBuilder.build(model, state);

        ConfigurationHash hash =
            hashCalculator.calculate(model, state.resolvedSelections());

        return ConfigurationResultAssembler.assemble(
            model,
            state,
            availableOptions,
            explanation,
            hash
        );
    }
}

Kita sengaja membuat evaluator kecil. Ini lebih mudah diuji daripada satu method besar validateConfiguration.


27. Catalog Model Loader

Engine butuh model catalog yang sudah siap dievaluasi.

public interface ConfigurationModelRepository {
    ConfigurationModel load(
        TenantId tenantId,
        ProductOfferingId offeringId,
        CatalogVersion catalogVersion
    );
}

Implementation bisa:

  1. cek Redis cache,
  2. jika miss, load dari PostgreSQL via MyBatis,
  3. assemble model,
  4. validate model,
  5. cache dengan key versioned.
config-model:v1:{tenantId}:{catalogVersion}:{offeringId}

Cache value harus immutable. Jika catalog publish baru, key baru. Jangan overwrite model lama.


28. Redis Cache Boundary

Redis digunakan untuk mempercepat read catalog model, bukan sebagai source of truth.

Allowed:

cache compiled configuration model
cache allowed option result for anonymous context if safe
cache reference data by version

Not allowed:

store final quote configuration only in Redis
store approval decision only in Redis
store installed base truth in Redis
mutate catalog model in Redis

TTL policy:

catalog config model: long TTL + versioned key
session result: short TTL optional
customer-context-sensitive result: careful; often not cacheable globally

29. Kafka Events

Configuration engine may publish events when configuration session changes or quote item snapshot is created.

Possible events:

ConfigurationEvaluated
ConfigurationBecameInvalid
ConfigurationRequiresApproval
QuoteItemConfigurationSnapshotCreated

But do not publish event for every UI keystroke.

Rule:

Publish durable event only when state matters to the business or another service.

For quick interactive evaluation, synchronous response is enough.

When quote item configuration is saved:


30. Interaction dengan Pricing Engine

Pricing engine tidak boleh mengevaluasi compatibility rule ulang secara liar.

Flow:

configuration result VALID/REQUIRES_APPROVAL
  -> pricing engine resolves charges for resolved selections
  -> pricing result references configurationHash

Pricing input:

public record PriceEvaluationCommand(
    TenantId tenantId,
    ProductOfferingId offeringId,
    CatalogVersion catalogVersion,
    ConfigurationHash configurationHash,
    List<ResolvedSelection> selections,
    CustomerContext customerContext
) {}

Jika configuration invalid, pricing harus ditolak atau diberi mode simulation khusus.

Jangan membuat pricing memberi harga untuk kombinasi produk yang invalid seolah-olah bisa dijual.


31. Interaction dengan Quote Lifecycle

Quote item menyimpan configuration snapshot.

Lifecycle:

Setelah quote submitted, configuration tidak boleh berubah tanpa quote revision.

Jika user mengubah configuration setelah submit:

create quote revision
copy previous quote item
apply new configuration
reprice
rerun approval if needed

32. Interaction dengan Order Decomposition

Order decomposition memakai resolved configuration snapshot.

Contoh:

router = WIFI6_ROUTER
installation = EXPRESS
static_ip = ONE_STATIC_IP

Decomposition menghasilkan fulfillment task:

Reserve Fiber Access
Ship WiFi 6 Router
Schedule Express Installation
Allocate Static IP
Activate Service

Jika configuration snapshot tidak lengkap, OMS tidak boleh decompose.

Order creation guard:

Quote item configuration status must be VALID or APPROVED_REQUIRES_APPROVAL_RESOLVED.

33. Installed Base Context

Untuk modify/disconnect/add-on, konfigurasi tidak dimulai dari kosong.

Contoh customer sudah punya:

Fiber Internet 500Mbps
Standard Router
No Static IP
12 months contract

User ingin upgrade ke 1Gbps dan add static IP.

Engine harus menggabungkan:

existing asset selections
requested modifications
catalog rules for target offering/action

Selection source:

INSTALLED_BASE
USER
SYSTEM

Rule bisa action-based:

For MODIFY bandwidth upgrade, router can remain unchanged if compatible.
For DISCONNECT, installation option is not required.
For ADD static IP, business customer rule applies.

34. Failure Modes

FailurePenyebabPencegahan
Validity berbeda antar requestrule order tidak deterministicfixed evaluation pipeline, sorted rules
Quote lama berubah hasil configmembaca catalog latestsnapshot + catalog version
UI tidak tahu kenapa option hilangengine hanya return booleanavailable option reason + explanation
Pricing untuk config invalidboundary lemahpricing requires valid configuration result
Rule conflict tidak ketahuancatalog publish tanpa validationrule graph validation
Engine lambatload catalog graph terus dari DBcompiled model cache by version
Approval tercampur invalidapproval signal dianggap errorseparate approval signal model
Modify order salahinstalled base context tidak dimodelkanselection source + action-based rules
Race update sessionno optimistic lockrow_version guard
Cache membaca model lamaunversioned keyversioned Redis key

35. Testing Strategy

Testing harus berbasis behavior, bukan sekadar method coverage.

Unit Test Evaluator

RequiredRuleEvaluatorTest
CardinalityEvaluatorTest
MutuallyExclusiveEvaluatorTest
DependencyEvaluatorTest
EligibilityEvaluatorTest
DefaultRuleEvaluatorTest
ApprovalSignalEvaluatorTest

Golden Scenario Test

residential_customer_selects_wifi6_router_with_12_month_term_is_valid
residential_customer_selects_5_static_ip_is_not_eligible
business_customer_selects_5_static_ip_is_valid
customer_owned_router_requires_approval
security_pack_and_gaming_pack_is_invalid
missing_router_is_incomplete

Snapshot Test

Pastikan canonical output stabil:

same input -> same configurationHash
selection order changed -> same configurationHash
catalog version changed -> different configurationHash

Catalog Validation Test

cycle detection
unknown target path
rule references retired option
default conflict
cardinality impossible
eligibility expression invalid

Integration Test

load catalog from PostgreSQL via MyBatis
cache model in Redis
save configuration session
save quote item snapshot
publish outbox event

36. Performance Model

Configuration engine harus cepat untuk interactive CPQ.

Hot path:

load compiled model
normalize selections
evaluate rules
compute available options
return result

Optimisasi awal:

  • compile catalog model once per catalog version,
  • index constraints by path/value,
  • avoid scanning all rules if target path irrelevant,
  • use immutable model,
  • precompute option group mapping,
  • cache reference data,
  • keep explanation configurable by detail level,
  • avoid DB calls during evaluation.

Rule:

evaluation should be CPU/local memory after model and context are loaded.

Jangan memanggil database untuk setiap constraint.


37. Minimal Implementation Plan

Implementasi bertahap:

Milestone 1 — Pure Domain Engine

  • build ConfigurationModel,
  • build SelectionSet,
  • build cardinality evaluator,
  • build dependency evaluator,
  • build mutually exclusive evaluator,
  • build result assembler,
  • run unit tests.

Milestone 2 — Catalog Loader

  • map catalog tables to internal model,
  • validate catalog graph,
  • add Redis cache,
  • add model hash.

Milestone 3 — API

  • implement POST /api/v1/configurations/evaluate,
  • validate request schema,
  • map domain result to response,
  • add problem detail errors.

Milestone 4 — Quote Integration

  • save configuration snapshot to quote item,
  • compute configuration hash,
  • mark quote item configured/incomplete/invalid,
  • trigger repricing.

Milestone 5 — Approval and Installed Base

  • add approval signals,
  • add installed base context,
  • add action-based rules.

38. Example End-to-End Scenario

Input:

{
  "offeringId": "fiber-1gbps-bundle",
  "catalogVersion": "cat-2026-07-01",
  "customerContext": {
    "segment": "RESIDENTIAL",
    "location": {
      "region": "JAKARTA",
      "serviceable": true
    }
  },
  "selections": [
    { "path": "router", "value": "WIFI6_ROUTER" },
    { "path": "static_ip", "value": "FIVE_STATIC_IP" },
    { "path": "contract_term", "value": "TERM_12_MONTHS" }
  ]
}

Rules:

WIFI6_ROUTER requires TERM_12_MONTHS or TERM_24_MONTHS.
FIVE_STATIC_IP requires segment BUSINESS.
Router exactly one required.

Output:

{
  "status": "INVALID",
  "issues": [
    {
      "code": "NOT_ELIGIBLE",
      "path": "static_ip",
      "selectedValue": "FIVE_STATIC_IP",
      "message": "5 Static IP is only available for business customers."
    }
  ],
  "approvalSignals": [],
  "configurationHash": "sha256:..."
}

Jika customer segment berubah menjadi BUSINESS, result menjadi VALID.


39. Anti-Patterns

Anti-Pattern 1 — Rule di UI

UI boleh membantu, tetapi source of truth tetap backend engine.

Anti-Pattern 2 — Rule di Pricing Engine

Pricing boleh memakai selection, tetapi tidak boleh menjadi validator compatibility utama.

Anti-Pattern 3 — Latest Catalog untuk Quote Lama

Quote lama harus membuka snapshot lama, bukan catalog terbaru.

Anti-Pattern 4 — Boolean Validation

valid: false tanpa reason tidak cukup untuk enterprise CPQ.

Anti-Pattern 5 — Free-Form Rule Language Terlalu Dini

Expression language yang terlalu bebas membuat rule sulit diuji, sulit dijelaskan, dan sulit dioptimalkan.

Anti-Pattern 6 — Tidak Ada Configuration Hash

Tanpa hash, sulit mendeteksi stale config, repricing need, atau duplicate state.


40. Kesimpulan

Product Configuration Engine adalah pusat correctness CPQ.

Engine yang baik harus:

  • deterministic,
  • explainable,
  • versioned,
  • snapshot-friendly,
  • testable,
  • independent dari UI,
  • independent dari pricing,
  • aware terhadap installed base,
  • able to produce approval signals,
  • safe untuk quote/order lifecycle.

Mental model utamanya:

configuration adalah graph + constraint + context + snapshot, bukan sekadar form field.

Di part berikutnya kita akan membangun engine kedua: Pricing Engine From Scratch. Pricing engine akan memakai resolved configuration dari part ini, lalu menghasilkan price breakdown, discount stacking, override signal, approval signal, dan price snapshot yang defensible.

Lesson Recap

You just completed lesson 30 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.