Overview
The Rules module provides a flexible business rules engine focused on:- Dynamic discounting rules
- Client status evaluation
- Configurable rule chains
- Visitor pattern for rule processing
- Reflection-based configuration
Architecture
Core Concepts
Offer Item
Represents an item being priced:OfferItem
public class OfferItem {
private final String productId;
private Money price;
private final ClientContext clientContext;
private final List<Modification> modifications;
public OfferItem apply(Modification modification) {
Money newPrice = modification.apply(this.price);
List<Modification> newMods =
new ArrayList<>(modifications);
newMods.add(modification);
return new OfferItem(
productId,
newPrice,
clientContext,
newMods
);
}
public Money finalPrice() {
return price;
}
}
// Price modification
public record Modification(
String ruleName,
Money originalPrice,
Money newPrice,
String reason
) {
public Money apply(Money price) {
return newPrice;
}
}
Offer Item Modifier
Base interface for all rule modifiers:OfferItemModifier
public interface OfferItemModifier {
OfferItem modify(OfferItem item);
}
Modifier Types
Chain Modifier
Executes multiple modifiers in sequence:ChainOfferItemModifier
public class ChainOfferItemModifier
implements OfferItemModifier {
private final List<OfferItemModifier> modifiers;
public ChainOfferItemModifier(
List<OfferItemModifier> modifiers
) {
this.modifiers = List.copyOf(modifiers);
}
@Override
public OfferItem modify(OfferItem item) {
OfferItem result = item;
for (OfferItemModifier modifier : modifiers) {
result = modifier.modify(result);
}
return result;
}
public static ChainOfferItemModifier of(
OfferItemModifier... modifiers
) {
return new ChainOfferItemModifier(
Arrays.asList(modifiers)
);
}
}
Named Modifier
Modifier with a name for identification:NamedOfferItemModifier
public record NamedOfferItemModifier(
String name,
OfferItemModifier modifier
) implements OfferItemModifier {
@Override
public OfferItem modify(OfferItem item) {
return modifier.modify(item);
}
}
Configurable Modifier
Modifier driven by configuration:ConfigurableItemModifier
public class ConfigurableItemModifier
implements OfferItemModifier {
private final ConfigProvider configProvider;
private final String configKey;
@Override
public OfferItem modify(OfferItem item) {
Config config = configProvider.getConfig(configKey);
// Apply discounts from config
List<Discount> discounts = config.getDiscounts();
OfferItem result = item;
for (Discount discount : discounts) {
if (discount.appliesTo(item)) {
result = discount.apply(result);
}
}
return result;
}
}
Empty Modifier
No-op modifier:EmptyModifier
public class EmptyModifier implements OfferItemModifier {
private static final EmptyModifier INSTANCE =
new EmptyModifier();
public static EmptyModifier instance() {
return INSTANCE;
}
@Override
public OfferItem modify(OfferItem item) {
return item; // No modification
}
}
Client Context
Client information for rule evaluation:ClientContext
public record ClientContext(
String clientId,
ClientStatus status,
Money totalExpenses,
LocalDate customerSince,
Map<String, Object> additionalData
) {
public boolean hasStatus(ClientStatus status) {
return this.status == status;
}
public Duration timeAsCustomer() {
return Duration.between(
customerSince.atStartOfDay(),
LocalDateTime.now()
);
}
}
public enum ClientStatus {
NEW,
REGULAR,
VIP,
PREMIUM,
INACTIVE
}
Client Finder
ClientFinder
public interface ClientFinder {
Optional<ClientContext> findClient(String clientId);
}
// Repository
public interface ClientContextRepository {
Optional<ClientContext> find(String clientId);
void save(ClientContext context);
}
Client Status Rules
Rules for determining client status:StatusRule
public interface ClientStatusVisitor<T> {
T visitNew();
T visitRegular();
T visitVip();
T visitPremium();
}
// Status-based rule
public class StatusRule implements OfferItemModifier {
private final Map<ClientStatus, OfferItemModifier> modifiers;
@Override
public OfferItem modify(OfferItem item) {
ClientStatus status =
item.clientContext().status();
OfferItemModifier modifier = modifiers.get(status);
if (modifier == null) {
return item;
}
return modifier.modify(item);
}
}
Expenses Rule
Discount based on total expenses:ExpensesRule
public class ExpensesRule implements OfferItemModifier {
private final List<ExpenseTier> tiers;
public record ExpenseTier(
Money minimumExpenses,
Percentage discount
) {}
@Override
public OfferItem modify(OfferItem item) {
Money totalExpenses =
item.clientContext().totalExpenses();
ExpenseTier tier = tiers.stream()
.filter(t -> totalExpenses
.isGreaterThanOrEqualTo(t.minimumExpenses()))
.max(Comparator.comparing(
ExpenseTier::minimumExpenses))
.orElse(null);
if (tier == null) {
return item;
}
Money discount = item.price()
.multiply(tier.discount());
Money newPrice = item.price().subtract(discount);
return item.apply(new Modification(
"expenses-discount",
item.price(),
newPrice,
String.format("%.0f%% discount for %s expenses",
tier.discount().value(),
totalExpenses)
));
}
}
Time Being Customer Rule
Discount based on customer tenure:TimeBeingCustomer
public class TimeBeingCustomer
implements OfferItemModifier {
private final List<TenureTier> tiers;
public record TenureTier(
Duration minimumDuration,
Percentage discount
) {}
@Override
public OfferItem modify(OfferItem item) {
Duration tenure =
item.clientContext().timeAsCustomer();
TenureTier tier = tiers.stream()
.filter(t -> tenure.compareTo(
t.minimumDuration()) >= 0)
.max(Comparator.comparing(
TenureTier::minimumDuration))
.orElse(null);
if (tier == null) {
return item;
}
Money discount = item.price()
.multiply(tier.discount());
Money newPrice = item.price().subtract(discount);
return item.apply(new Modification(
"tenure-discount",
item.price(),
newPrice,
String.format("%.0f%% loyalty discount (customer for %d years)",
tier.discount().value(),
tenure.toDays() / 365)
));
}
}
Discount Appliers
Different ways to apply discounts:public class Amount implements OfferItemModifier {
private final Money discountAmount;
@Override
public OfferItem modify(OfferItem item) {
Money newPrice = item.price()
.subtract(discountAmount);
return item.apply(new Modification(
"fixed-amount",
item.price(),
Money.max(newPrice, Money.zeroPln()),
"Fixed discount: " + discountAmount
));
}
}
Dynamic Configuration
Reflection-based configuration:Reflection Configuration
public class ReflectionDynamicConfig {
private final Map<String, Discount> discounts;
public void addDiscount(
String name,
String applierClass,
Map<String, Object> parameters
) throws ReflectiveOperationException {
// Load applier class
Class<?> clazz = Class.forName(applierClass);
// Create instance with parameters
Object applier = createInstance(clazz, parameters);
discounts.put(name, new Discount(
name,
(OfferItemModifier) applier
));
}
private Object createInstance(
Class<?> clazz,
Map<String, Object> params
) throws ReflectiveOperationException {
// Find constructor and inject parameters
// Implementation uses reflection
}
}
@Discount(name = "vip-discount")
public class VipDiscount implements OfferItemModifier {
@DiscountParam("percentage")
private final BigDecimal percentage;
// Reflection creates instance
}
Modifier Factory
OfferItemModifierFactory
public class OfferItemModifierFactory {
public OfferItemModifier createChain(
ClientContext clientContext
) {
List<OfferItemModifier> modifiers =
new ArrayList<>();
// Add status-based discounts
modifiers.add(createStatusModifier(clientContext));
// Add expenses-based discounts
modifiers.add(createExpensesModifier(clientContext));
// Add tenure-based discounts
modifiers.add(createTenureModifier(clientContext));
return ChainOfferItemModifier.of(
modifiers.toArray(new OfferItemModifier[0])
);
}
private OfferItemModifier createStatusModifier(
ClientContext context
) {
return switch (context.status()) {
case NEW -> EmptyModifier.instance();
case REGULAR -> new PercentageAccumulated(
Percentage.of(5)
);
case VIP -> new PercentageAccumulated(
Percentage.of(10)
);
case PREMIUM -> new PercentageAccumulated(
Percentage.of(15)
);
case INACTIVE -> EmptyModifier.instance();
};
}
}
Visitor Pattern for Rules
Modifier Visitor
public interface OfferItemModifierVisitor<T> {
T visitChain(ChainOfferItemModifier chain);
T visitConfigurable(ConfigurableItemModifier configurable);
T visitEmpty(EmptyModifier empty);
T visitNamed(NamedOfferItemModifier named);
}
// Example: Rule explanation visitor
public class RuleExplanationVisitor
implements OfferItemModifierVisitor<String> {
@Override
public String visitChain(ChainOfferItemModifier chain) {
return "Chain of rules: " +
chain.modifiers().stream()
.map(m -> m.accept(this))
.collect(Collectors.joining(" -> "));
}
@Override
public String visitNamed(NamedOfferItemModifier named) {
return named.name();
}
}
Real-World Example: E-commerce Discounting
Complete Discount Flow
// 1. Load client context
ClientContext client = clientFinder.findClient("CUST-123")
.orElse(new ClientContext(
"CUST-123",
ClientStatus.NEW,
Money.zeroPln(),
LocalDate.now(),
Map.of()
));
// 2. Create offer item
OfferItem item = new OfferItem(
"PRODUCT-001",
Money.pln(1000),
client,
List.of()
);
// 3. Build modifier chain
OfferItemModifier chain = ChainOfferItemModifier.of(
// Status discount (10% for VIP)
new StatusRule(Map.of(
ClientStatus.VIP,
new PercentageAccumulated(Percentage.of(10))
)),
// Expenses discount (5% for >10k PLN)
new ExpensesRule(List.of(
new ExpensesRule.ExpenseTier(
Money.pln(10000),
Percentage.of(5)
)
)),
// Tenure discount (3% for 2+ years)
new TimeBeingCustomer(List.of(
new TimeBeingCustomer.TenureTier(
Duration.ofDays(730),
Percentage.of(3)
)
))
);
// 4. Apply rules
OfferItem discounted = chain.modify(item);
// 5. Show result
System.out.println("Original: " + item.price());
System.out.println("Final: " + discounted.finalPrice());
for (Modification mod : discounted.modifications()) {
System.out.println(" - " + mod.reason() +
": " + mod.originalPrice() +
" -> " + mod.newPrice());
}
// Output:
// Original: 1000 PLN
// Final: 823.85 PLN
// - 10% VIP discount: 1000 -> 900
// - 5% expenses discount: 900 -> 855
// - 3% loyalty discount: 855 -> 823.85
Configuration Types
public class SampleStaticConfig {
public static OfferItemModifier getModifier() {
return ChainOfferItemModifier.of(
new NamedOfferItemModifier(
"early-bird",
new PercentageAccumulated(
Percentage.of(15)
)
),
new NamedOfferItemModifier(
"bulk-discount",
new Amount(Money.pln(100))
)
);
}
}
Best Practices
Chain Rules
Combine multiple rules using ChainOfferItemModifier
Immutable Items
Always return new OfferItem, never modify in place
Track Changes
Record all modifications for audit trail
Test Rules
Unit test each rule modifier independently
