SwadhyAI Notes

All Low-Level Design notes

LLD case study

Rule engine

Marketing wants a cart promotion: premium shopper, cart over 1000, coupon still valid, take 10% off. Next week they want a first-time-buyer campaign on the same checkout path. If those checks live as nested if statements inside evaluate(), every new campaign opens the core loop.

A rule engine evaluates composable (and/or/not) and runs matching without growing that nest. Rules are objects you register. Adding that campaign must not edit the engine loop. That is the whole lab.

Safe extension path

1

A yes/no query you compose with and, or, and not; leaves stay side-effect free.

2

Rule = when + then

Pair a specification with an action and a name you can log.

3

Engine loop

Walk the list, run the match policy, and never hard-code "premium".

4

Register a leaf class. If you open evaluate(), the composite failed.

What actually happens when campaigns pile up

The Tuesday promotion above is the default shape of this problem. Each campaign is a boolean tree of leaf checks. A naive evaluate() inlines those trees as business ifs. The next campaign then edits the same method, and the composite diagram on the whiteboard never becomes the extension point. See extensibility: this page is the advanced lab for that idea.

A is a predicate. It answers isSatisfiedBy(fact). You compose leaves with and/or/not, which is the Composite tree (one spec that is really several). A Rule pairs that query with an . The evaluator walks a list or tree of rules, finds the (or all matches; say which), and runs the action. That loop does not know "premium" or "cart total." Those are leaf specifications. See Specification.

Facts should be a small context object, not a God map of strings (a loose key/value bag) if you can help it. CartTotalSpec needs a cart. CouponSpec needs a coupon and a for expiry. Actions can be strategies: discount percent, free shipping, reject. Specs stay queries. That split is what lets a test ask "would this match?" on an expired coupon without firing a discount.

Which bucket is this code in: query or command?

Before you write the next class, sort the work. The same object cannot honestly be both a dry-run question and a mutation, and mixing them is how tests start to lie.

  • Answer yes or no with isSatisfiedBy; compose leaves using and, or, and not.
  • Keep the spec side-effect free so a dry run never sends email or mail.
  • Put expiry on CouponSpec with an injected Clock, never LocalDate.now().

  • Apply the side effect: percent off, free shipping, or reject the cart.
  • Mutate the cart in Action.apply; the match query must stay separate.
  • Treat actions as strategies you swap, not branches inside evaluate().

How to keep the loop closed

Once query and command are split, three decisions keep evaluate() from growing a nest of business ifs.

1. Where does the next campaign go? A new leaf specification plus a . If you cannot add that leaf without opening the evaluator, the composite was only a diagram (see the playbook below).

2. What is the match policy? Say . First match breaks after apply. All match stacks discounts, and you must say whether order in the list is priority.

3. What does config own? A maps names to specs. JSON parsing stays in that factory. The engine still does not parse JSON, and leaves stay classes.

Simple rule of thumb

Leaves hold the business. The engine holds the loop. Specs decide true or false; they do not send mail. Inject on coupon expiry. That is : campaigns change, the evaluator loop does not.

From problem to mechanism

Here is the campaign story again, slowed down so you can see where the nested-if version fails and what the object split changes.

Problem. Promotions arrive as boolean trees on a cart. Product will add another campaign next week. The evaluator must stay a for-loop.

Naive system. Write the campaign ifs from the hero inside the engine. Coupon expiry calls LocalDate.now(). The that matches also applies the discount, so you cannot ask "would this match?" without firing the .

Break it. The next campaign edits the core. Expired-coupon tests flake because the clock is the wall clock. Two rules both apply because nobody said , and you get a silent double discount.

Mechanism. Register Rule objects: a named when-spec plus a then-action. Compose leaves with and/or/not. Inject on CouponSpec. A maps a rule name from config to a spec instance. The loop never knows a campaign leaf; PremiumSpec does.

Leaves hold the business

The mechanism names leaf specifications. This tree is the running promotion from the hero (see Numbers to know in the playbook). The rule node does not embed those checks.

Diagram text: the exact Mermaid definition below preserves the nodes, labels, and connections. Enable JavaScript to view the rendered diagram.
Read the diagram definition
flowchart TB
    R["Rule: first match"]
    S["Spec: premium AND cart > 1000 AND NOT couponExpired"]
    A["Action: 10% discount"]
    P[PremiumSpec]
    C[CartTotalSpec]
    X[Not CouponExpiredSpec]
    R --> S
    S --> P
    S --> C
    S --> X
    R --> A

Playbook: add a campaign without opening evaluate

The proof of this lab is a closed RuleEngine. You add FirstTimeBuyerSpec, register it, compose it into a Rule with PercentOffAction(10), and append the rule to the list. evaluate() is still the for-loop. If you typed if (cart.user().firstOrder()) inside the engine, you failed the design goal even if the discount is correct.

1. Leaf spec

FirstTimeBuyerSpec

New class plus a entry. The evaluator stays closed. That is the first follow-up change this design is built for.

2. Match policy

break or no break

uses break after apply. All matching rules: remove break and document stacking discounts so a double 10% is an explicit product choice.

3. Config later

name → spec instance

JSON rule config is a . The engine still does not parse JSON. Leaves stay classes. That is .

Who owns what

  1. 01

    Specification<T>

    A query you compose with and/or/not; keep it a dry-run question.

  2. 02

    Action<T>

    A command such as discount, reject, or free shipping on the cart.

  3. 03

    Rule<T>

    A named pair of when-specification plus then-action, used in logs.

  4. 04

    RuleEngine<T>

    A loop that applies first-match or all-match and never hard-codes a leaf.

  5. 05

    Registry / factory

    Maps a config name onto a specification instance you can register.

  6. 06

    Clock

    Coupon expiry source; freeze it so expired-coupon tests stay deterministic.

Java API sketch

java
interface Specification<T> {
  boolean isSatisfiedBy(T fact);
  default Specification<T> and(Specification<T> other) {
    return fact -> this.isSatisfiedBy(fact) && other.isSatisfiedBy(fact);
  }
  default Specification<T> or(Specification<T> other) {
    return fact -> this.isSatisfiedBy(fact) || other.isSatisfiedBy(fact);
  }
  default Specification<T> not() {
    return fact -> !this.isSatisfiedBy(fact);
  }
}

interface Action<T> {
  void apply(T fact);
}

class Rule<T> {
  final String name;
  final Specification<T> when;
  final Action<T> then;
}

class RuleEngine<T> {
  void evaluate(T fact, List<Rule<T>> rules) {
    for (Rule<T> rule : rules) {
      if (rule.when.isSatisfiedBy(fact)) {
        rule.then.apply(fact);
        break; // first match; or run all; say which
      }
    }
  }
}

class PremiumSpec implements Specification<Cart> {
  public boolean isSatisfiedBy(Cart cart) { return cart.user().premium(); }
}

Tests that prove the seams

The seam test below adds the new campaign as its own rule. It never opens the engine type.

java
@Test
void firstTimeBuyerDoesNotEditEngine() {
  RuleEngine<Cart> engine = new RuleEngine<>();
  List<Rule<Cart>> rules = List.of(
      new Rule<>("ftb", new FirstTimeBuyerSpec(), new PercentOff(10)));
  Cart cart = Cart.firstOrder(Money.of(500, "INR"));
  engine.evaluate(cart, rules);
  assertEquals(Money.of(450, "INR"), cart.total());
}

Numbers to know

  • Running example: premium and cart over 1000 and not expired coupon yields 10% off.
  • Lab budget: forty-five minutes across five beats, listed in the Wrap-up exercises.
  • First-match uses break after apply; forgetting it double-applies that 10% silently.

Invalid cases, and what still breaks after the fix

Putting the pieces together, these are the designs that fail the lab, the concurrency limits of this loop, and the ways a correct object split can still go wrong.

Invalid cases

  • Business ifs inside evaluate() mean the next campaign edits the core.
  • A specification that sends email cannot be asked would-this-match without firing.
  • Opening RuleEngine for a new campaign means leaves were not the extension point.
  • Coupon expiry via LocalDate.now() breaks tests; inject Clock and freeze the date.
  • Silent apply of two first-match rules stacks discounts; say first-match versus all-match.

Concurrency

Specs should be immutable and side-effect free, so they are safe to share across threads. Actions that mutate a cart should run on one thread per fact, or lock the cart. The engine loop is not a distributed rules cluster.

Still fails when…

  • You chose first-match and forgot break, so two rules apply and stack discounts.
  • CouponSpec reads the wall clock, so yesterday/today expiry tests cannot freeze time.
  • JSON parsing leaks into evaluate(), so config changes reopen the engine loop.

Trade-offs

  • First-match is simple to reason about, but later rules never run for that fact.
  • All-match enables stacking discounts, and you must document list-order priority.
  • A typed cart fact beats a God map; each leaf needs the fields it reads.

Wrap-up

You have seen the failure, the mechanism, and the seam test. Use the timed lab to check the ideas stuck, then follow the related pages for Composite, Specification, Strategy, and extensibility.

How to explain this in an interview

evaluate() is not the campaign if from the hero. It is a loop over Rule objects. Leaves hold the business. The engine holds the loop. Specs are queries. Actions are commands. belongs on CouponSpec. A new campaign is a class, not a branch (see the playbook above).

Exercises: forty-five minutes

  1. 0–8Build Specification.and/or/not and one leaf, PremiumSpec, as a composable query.
  2. 8–20Wire Rule plus a first-match loop and a PercentOff action on the cart.
  3. 20–32Add CouponSpec with injected Clock so an expired coupon does not discount.
  4. 32–40Register the next campaign as a new leaf without opening evaluate().
  5. 40–45Say out loud whether the engine is all-match or first-match.

What the follow-up changes

  • Next campaign leaf: add the spec and registry entry; keep the evaluator closed.
  • All matching rules: remove break and document how stacking discounts should apply.
  • JSON rule config: a factory maps names to specs; evaluate() still has no business ifs.

You're done when

The next campaign rule is its own class (or leaf), not a new if inside evaluate (see the playbook). If adding a campaign means editing the evaluate loop, the composite failed. Leaves hold campaign predicates; the engine only walks the tree. A that sends email cannot be asked "would this match?" in a dry run. That is why actions are a separate type.