Build

What to build.

CEL evaluation architecture, the PromotionEvaluationContext struct, two-repo delta, and phased delivery model. Engineering audience.

The structural gap

Email is not in the Go service domain struct. It is not in ExecutePromotionRequest. The Sony use case is structurally impossible today.

bigcommerce/promotions · internal/domain/customer.go current state
type Customer struct {
    ID      CustomerID  // uint32
    GroupID GroupID     // uint32
}
// Email is not here. It is not passed in ExecutePromotionRequest.
// evaluateCustomerGroup() checks GroupID only.

The RPC serializer maps customerProto.GetId() and customerProto.GetGroupId() — nothing else. Billing email is in the cart record at checkout step 4 but is not extracted into the promotion context. Source: bc-promotions-engine.md ↗

Recommended architecture: CEL + typed context

Option C from the architectural options analysis. CEL (Common Expression Language) is non-Turing-complete, Go-native, and production-proven in Kubernetes admission webhooks. The PromotionEvaluationContext struct is the central abstraction — assemble it once per checkout call, evaluate all conditions against it. Source: architectural-options.md ↗

bigcommerce/promotions · internal/domain/context.go (new) proposed
type PromotionEvaluationContext struct {
    Customer struct {
        ID          CustomerID
        GroupID     GroupID
        Email       string    // Phase 1 — free from billing_address in cart record
        EmailDomain string    // Phase 1 — derived from Email at assembly time
        Company     string    // Phase 2 — one Redis-cached gRPC call (~2–5ms)
    }
    Cart    CartContext
    Channel ChannelContext
}

type CartContext struct {
    Subtotal     float64
    ItemCount    int
    CouponCodes  []string
}

type ChannelContext struct {
    ID           int
    CurrencyCode string
}

CEL evaluation flow

checkout step 4 RequestToContextTransformer PromotionEvaluationContext{ customer{email, emailDomain}, cart, channel } cel.Env.Eval(expression, ctx) bool discount applied or skipped

The context assembly step is the critical path surface. All conditions test against the assembled struct; the CEL env knows nothing about "email domain" — it evaluates typed predicates over the context object.

Two-repo delta

bigcommerce/bigcommerce PHP monolith
  • + CustomerEmailDomainCondition — new typed condition class in the conditions DSL
  • ~ RequestToContextTransformer — extract billing_address.email into the request
  • + promotions-manager microapp — register "Email domain" condition type in the CP rule builder UI
  • + Schema for new condition type parameters (operator enum + value string)
bigcommerce/promotions Go service
  • ~ internal/domain/customer.go — add Email string field
  • ~ RPC serializer (get_eligible_featured_promotions_serializer.go) — map billing_address.email from proto
  • + internal/expression/ — new package: CEL env setup + ExpressionCondition type
  • ~ go.mod — add github.com/google/cel-go

Why the Go service is the right home for the CEL model: the PHP monolith's typed condition DSL handles Phase 1. The Go service extraction is where CEL expression evaluation belongs long-term — design it correctly now while the struct is a 2-field stub, not after it has 20 callers. Source: architectural-options.md ↗

Phased delivery

Phase 1 minimal scope

Email domain condition (PHP)

  • + CustomerEmailDomainCondition in the typed DSL
  • ~ RequestToContextTransformer maps billing_address.email
  • + CP rule builder UI registers the new condition type

Zero additional service calls. Email is already in the cart record at step 4.

Phase 2 medium scope

CEL + Go service extraction

  • + internal/expression/ — CEL env + ExpressionCondition
  • + PromotionEvaluationContext replaces bare domain.Customer
  • ~ Go service extraction reaches customer-attribute evaluation path

Design the CEL model correctly now — the Go struct is a 2-field stub with zero callers yet.

Phase 3 largest scope

Custom attributes via customers-scala

  • + Customer materialized-view snapshot (Redis, domain events)
  • + gRPC call to customers-scala for custom form fields
  • + Company name, job role, and arbitrary custom attribute keys

Latency budget: 5–15ms gRPC call amortized via snapshot. Async refresh on customer.updated events.

Engineering note

Guest checkout email is already paid for

billing_address.email is in the cart record at checkout step 4 for all customers — guest and authenticated alike. No new service call. Shopify's email domain targeting requires sign-in; guest customers are excluded. This is a real differentiation point for B2B use cases where buyers check out as guests but enter corporate email addresses.

Decision lineage