Skip to content

Rethinking Expense Validation: Building a Pluggable Rules Engine in Java

Fatur Ewing (Community admin) 4 min read

Software systems rarely collapse from one bad decision. They decay through a hundred small ones. A reimbursement service is a familiar example: day one has four clean checks; month six has a 200-line method no one wants to touch.

Expense platforms sit at the worst intersection of that problem. Policies change constantly. Limits differ by department, region, seniority, and vendor. Finance wants a new rule on Tuesday and expects it live by Wednesday. If every policy change means a pull request and a deploy, the architecture is already the bottleneck.

This rewrite walks through that evolution: hardcoded conditionals, rules as Java types, then configuration-driven evaluators that non-engineers can change without shipping code.

The real problem

The hard part is not “check the amount.” It is policy volatility at tenant scale.

Typical constraints look like this:

  • No expense above a global cap (say ₹50,000)
  • Travel needs manager approval first
  • Some vendors are banned
  • Meal limits differ by department
  • Regions have their own currency and tax rules
  • Seniority changes approval thresholds and override rights

The design question is simple: how do you add or tweak rules without rewriting the core validator and redeploying the service every time?

The naive approach

The first version is almost always a service method full of if statements.

public class ExpenseValidator {

    public ValidationResult validate(Expense expense, List<String> restrictedVendors) {
        if (expense.getAmount().compareTo(new BigDecimal("50000")) > 0) {
            return ValidationResult.fail("Amount exceeds the allowed limit of 50000");
        }

        if (expense.getCategory() == Category.TRAVEL && !expense.isManagerApproved()) {
            return ValidationResult.fail("Travel expenses require manager approval");
        }

        if (restrictedVendors.contains(expense.getVendor())) {
            return ValidationResult.fail("Vendor is restricted");
        }

        if (expense.getCategory() == Category.MEAL
                && expense.getAmount().compareTo(new BigDecimal("2000")) > 0) {
            return ValidationResult.fail("Meal expenses cannot exceed 2000");
        }

        return ValidationResult.success();
    }
}

It works. It is easy to debug. Tests are cheap.

Then finance restricts software purchases to an approved vendor list. HR wants intern rules that differ from full-time staff. Mumbai and London disagree on currency caps. Project codes get their own workflows.

The method grows. Every change risks breaking a rule written by someone who left last year. Cyclomatic complexity explodes. Combinatorial tests become a tax on every edit.

The if is not the enemy. Coupling policy to the execution path is.

Approach 1: one class per rule

Pull each policy behind a shared contract.

public interface ExpenseRule {
    ValidationResult validate(Expense expense);
}

Each rule owns one concern and its own configuration.

public class MaxAmountRule implements ExpenseRule {
    private final BigDecimal maxAmount;

    public MaxAmountRule(BigDecimal maxAmount) {
        this.maxAmount = maxAmount;
    }

    @Override
    public ValidationResult validate(Expense expense) {
        if (expense.getAmount().compareTo(maxAmount) > 0) {
            return ValidationResult.fail("Amount exceeds the allowed limit");
        }
        return ValidationResult.success();
    }
}
public class ManagerApprovalRule implements ExpenseRule {
    @Override
    public ValidationResult validate(Expense expense) {
        if (expense.getCategory() == Category.TRAVEL && !expense.isManagerApproved()) {
            return ValidationResult.fail("Travel expenses require manager approval");
        }
        return ValidationResult.success();
    }
}
public class RestrictedVendorRule implements ExpenseRule {
    private final Set<String> restrictedVendors;

    public RestrictedVendorRule(Set<String> restrictedVendors) {
        this.restrictedVendors = restrictedVendors;
    }

    @Override
    public ValidationResult validate(Expense expense) {
        if (restrictedVendors.contains(expense.getVendor())) {
            return ValidationResult.fail("Vendor is restricted: " + expense.getVendor());
        }
        return ValidationResult.success();
    }
}

The validator no longer knows about meals, travel, or blacklists. It only runs a list.

public class ExpenseValidator {
    private final List<ExpenseRule> rules;

    public ExpenseValidator(List<ExpenseRule> rules) {
        this.rules = rules;
    }

    public ValidationResult validate(Expense expense) {
        for (ExpenseRule rule : rules) {
            ValidationResult result = rule.validate(expense);
            if (!result.isValid()) {
                return result;
            }
        }
        return ValidationResult.success();
    }
}

New policy? New class. The orchestrator stays closed. That is the Open/Closed Principle in practice, not as a slogan.

Why classes still fall short

In production, “add a rule” does not mean write a class, open a PR, wait for review, and ship a JAR.

It means the CFO raised the hotel cap from ₹50,000 to ₹75,000 on Tuesday morning and expects the admin UI to apply it immediately.

If business users own the policies, compiled Java types are the wrong storage format. You need rules as data.

Approach 2: configuration-driven rules

Store rule metadata outside the binary—usually a database or config store.

CREATE TABLE expense_rules (
    id VARCHAR(64) PRIMARY KEY,
    organization_id VARCHAR(64) NOT NULL,
    expense_category VARCHAR(32) NOT NULL,
    rule_type VARCHAR(64) NOT NULL,
    configuration JSON NOT NULL,
    priority INT NOT NULL,
    enabled BOOLEAN NOT NULL
);

Example rows:

organization_id expense_category rule_type configuration priority enabled
ORG_123 ALL MAX_AMOUNT {"limit": 50000} 10 true
ORG_123 TRAVEL MANAGER_APPROVAL {"required": true} 20 true
ORG_123 MEAL MAX_AMOUNT {"limit": 2000} 10 true
ORG_123 ALL RESTRICTED_VENDOR {"vendors": ["ABC"]} 30 true

Changing a limit is an update, not a deploy.

Evaluation logic stays in code. Configuration lives in the record.

public interface RuleEvaluator {
    ValidationResult evaluate(Expense expense, Rule ruleRecord);
}
public class MaxAmountEvaluator implements RuleEvaluator {
    @Override
    public ValidationResult evaluate(Expense expense, Rule rule) {
        BigDecimal limit = new BigDecimal(rule.getConfiguration().get("limit").asText());
        if (expense.getAmount().compareTo(limit) > 0) {
            return ValidationResult.fail("Amount exceeds configured limit of " + limit);
        }
        return ValidationResult.success();
    }
}

A registry maps rule_type to the evaluator that knows how to read that JSON.

public class RuleEvaluatorRegistry {
    private final Map<RuleType, RuleEvaluator> evaluators;

    public RuleEvaluatorRegistry(Map<RuleType, RuleEvaluator> evaluators) {
        this.evaluators = evaluators;
    }

    public RuleEvaluator getEvaluator(RuleType type) {
        RuleEvaluator evaluator = evaluators.get(type);
        if (evaluator == null) {
            throw new IllegalArgumentException("Unsupported rule type: " + type);
        }
        return evaluator;
    }
}

Runtime flow: load rules for the tenant and category, resolve each type through the registry, evaluate in priority order.

You still add a new kind of rule in code. You no longer ship code to change a limit, a vendor list, or an enabled flag.

Trade-offs you actually feel in production

Classes as rules give you compile-time safety, cheap refactors, and simple tests. They also lock every tweak to an engineer and a pipeline.

Config as rules give finance autonomy and make multi-tenant limits natural (Company A at ₹50k, Company B at ₹100k). They also turn you into the author of a small interpreter. You must validate JSON, reject bad configs, version rules, and stop the engine from becoming a poorly designed programming language stuffed into a column.

Keep rule types narrow. Do not invent a general-purpose script runtime inside configuration.

Caching

Do not hit the database on every expense. Cache rules (Caffeine locally, Redis if you have multiple nodes) and invalidate when an admin writes a policy.

Multi-tenancy

organization_id must travel from request context through repository, cache key, and evaluator. One missed filter is a data leak.

Fail-fast vs collect-all

Decide whether the first failure stops the run or whether you return every violation so the employee can fix them in one pass. Financial systems also need an audit trail: rule id, version, timestamp, and a snapshot of the config that actually ran. “A rule failed” is not an answer an auditor will accept.

What to build first

Do not start with a platform if you have three static policies owned by engineering.

Start with the interface and discrete rule classes. That already separates orchestration from policy.

Move definitions into storage when non-engineers need to change them, or when tenants diverge enough that hardcoded constants become a tax. Keep the evaluator registry. That is the piece that survives the migration.

Good design is not the most elaborate pattern you can name. It is complexity that matches how fast the business actually changes. fmmfX


Reactions

Share this post

0 replies

Sign in with your Maxlayer account to join the conversation.