Skip to main content

Command Palette

Search for a command to run...

Rethinking Expense Validation: From Hardcoded If Statements to Pluggable Engines in Java

Moving from messy conditional statements to extensible, configuration-driven rule engines in enterprise Java applications.

Updated
11 min readView as Markdown
Rethinking Expense Validation: From Hardcoded If Statements to Pluggable Engines in Java
A
Software Engineer at OpenText passionate about building scalable web applications and backend systems. I work mainly with Java, Spring Boot, Python, and modern web technologies. I enjoy creating things for the internet, exploring new tech, and sharing what I learn along the way.

You are sitting in a system design interview, or worse, staring at a pull request where a junior engineer has just added their fourteenth nested conditional statement to a reimbursement service. Software architecture rarely fails because of a single catastrophic decision. It rots slowly through the accumulation of small, expedient shortcuts taken under deadline pressure.

Modern enterprise backend development often forces us to build systems that must adapt to rules we cannot predict in advance. When an application starts out, requirements feel bounded. You write a few clean methods, deploy your service, and move on to the next ticket. Within a year, the business grows, new regional offices open, compliance regulations shift, and the simple validation logic you wrote on day one becomes a monolithic bottleneck.

Reimbursement platforms are a classic battleground for this architectural decay. Employees submit expenses for hotel stays, client dinners, equipment purchases, and travel. The validation engine needs to evaluate these requests against a constantly expanding matrix of corporate policies.

The Problem Statement

The core engineering challenge in designing an expense validation platform revolves around policy volatility and tenant scale. An organization needs the ability to define and enforce diverse validation rules without constantly modifying the core source code.

Consider the typical policy constraints demanded by finance departments:

  • No single expense can exceed fifty thousand rupees.

  • Travel expenses require prior manager approval.

  • Certain vendors are completely blacklisted.

  • Different departments have distinct spending limits for meals.

  • Regional offices have unique currency restrictions and tax validation rules.

  • Employee seniority alters approval thresholds and override privileges.

The central design question is straightforward. How do we allow organizations to introduce new expense rules without constantly modifying the core validation code and triggering a full redeployment every time a policy tweak occurs?

The Naive Approach

Most engineering teams start with the most obvious approach. You write a service class, inject a repository, and write a series of conditional statements over the incoming request object. It works on day one. It is easy to debug, and you can write unit tests in your sleep.

public class ExpenseValidator {

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

        // Hardcoded category-specific rule for travel
        if (expense.getCategory() == Category.TRAVEL && !expense.isManagerApproved()) {
            return ValidationResult.fail("Travel expenses require manager approval");
        }

        // Hardcoded check against a list of blacklisted vendors
        if (restrictedVendors.contains(expense.getVendor())) {
            return ValidationResult.fail("Vendor is restricted");
        }

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

        return ValidationResult.success();
    }
}

This code directly evaluates incoming expense parameters using raw conditional blocks inside a single method. While straightforward initially, it mixes orchestration logic with direct policy enforcement, setting the stage for tight coupling.

Problems with the Naive Approach

This code looks clean enough when you only have four rules. Give it six months. Now the finance department wants to restrict software purchases to approved vendors only. The human resources team wants different rules for interns versus full-time employees. The regional office in Mumbai has a different currency limit than the office in London. Department heads want custom approval workflows based on project codes.

Your conditional block swells to two hundred lines. Every new business policy requires modifying the core validation class. Every modification risks breaking existing rules that were written by someone who left the company nine months ago. Cyclomatic complexity climbs into the stratosphere. Testing every permutation becomes a combinatorial nightmare where changing one line of code breaks tests in an entirely unrelated module.

The problem is not that if statements are inherently evil. The real issue is that business policy has become tightly coupled to the core execution engine. Whenever business rules change, code has to change. That is a structural bottleneck.

Approach 1: Rules as Java Implementations

To decouple policy from execution, we need to encapsulate individual rules into discrete units. Instead of one giant method knowing about every business nuance, we define a common contract for a rule.

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

This interface establishes a single contract for all rules. Any policy implementation must accept an expense and return a validation result, hiding its internal validation logic behind this common method.

Now, every business policy becomes its own class implementing this interface. Let us look at a few concrete examples written in modern Java.

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();
    }
}

This class encapsulates a single responsibility: checking whether an expense amount exceeds a configured threshold, keeping the numeric limit safely encapsulated as an immutable field.

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();
    }
}

Here, the rule isolates travel-specific validation logic, checking workflow approval flags only when the expense category matches travel.

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();
    }
}

This rule manages blacklisted vendors by performing an efficient set lookup against the expense vendor attribute.

The orchestrator, our ExpenseValidator, no longer knows anything about meals, travel, or vendor blacklists. It simply accepts a collection of rules and executes them sequentially.

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();
    }
}

The orchestrator operates entirely against the abstract ExpenseRule interface, meaning it can iterate over an injected collection of rules without knowing their concrete types or business implementations.

This design gives us code-level extensibility. If we need to introduce a MealApprovalRule, we write a new class implementing ExpenseRule. We do not touch ExpenseValidator. We are adhering to the Open/Closed Principle without needing to recite software design textbooks.

Can We Further Improve This?

This object-oriented abstraction feels satisfying, but it exposes a glaring limitation once you build software for actual enterprises.

What does "adding a new rule" mean in production?

To a developer, it means writing a class, writing unit tests, opening a pull request, waiting for code review, merging, and deploying a new version of the artifact to production.

To a finance administrator, that definition is absurd. When the CFO decides on a Tuesday morning that the hotel limit needs to increase from fifty thousand rupees to seventy-five thousand rupees, they expect to click a button in an admin portal and see the change take effect immediately. They do not want to wait for a sprint deployment cycle, a staging verification, or a zero-downtime rolling update managed by the infrastructure team.

This distinction separates a basic CRUD application from a true enterprise policy platform. If business users need to create or modify policies without a code deployment, Java classes compiled into a JAR file will not cut it. We need configuration-driven rules.

Approach 2: Configuration-Driven Rules

To let administrators manage policies dynamically, rule definitions must live outside the codebase, typically in a relational database or a distributed configuration store.

Consider a database table designed to hold rule definitions:

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
);

This relational schema stores rule metadata and flexible JSON configuration payloads per tenant organization, allowing dynamic policy updates at runtime.

In this table, individual rows represent active policies for specific tenants.

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

Now, changing a hotel or meal limit is simply an update statement or an API call that modifies the JSON payload in the database. No code deployment required.

To execute these database-backed rules, we invert our architecture. Instead of hardcoded rule classes containing both logic and configuration, we separate the evaluation logic from the rule metadata using a generic evaluator interface.

public interface RuleEvaluator {
    ValidationResult evaluate(Expense expense, Rule ruleRecord);
}

This generic evaluator interface decouples the execution algorithm from the database record, accepting both the incoming expense and the raw rule configuration.

We implement specific evaluators that know how to parse the JSON configuration for a given rule type.

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();
    }
}

This evaluator parses the database JSON payload dynamically to extract the specific threshold limit set by the administrator for that tenant.

A registry or factory pattern maps a string or enum representation of the rule type to its corresponding evaluator implementation.

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;
    }
}

The registry maps rule type identifiers to their corresponding evaluator implementations, allowing the engine to dynamically resolve the correct logic handler at runtime.

The runtime validation flow changes from executing hardcoded objects to fetching database configurations and routing them through the appropriate evaluators.

Architectural Trade-Offs and Operational Realities

Both approaches carry distinct operational costs. Choosing the right one depends entirely on who owns the rules and how often they change.

Approach one gives you strong compile-time type safety. Refactoring is trivial because your IDE can trace usages instantly. Unit testing is straightforward since everything is pure code. The downside is rigidity. Every policy tweak requires an engineering resource and a deployment pipeline.

Approach two provides runtime flexibility and empowers non-technical users. It fits multi-tenant platforms where Company A wants a fifty thousand rupee limit and Company B wants one hundred thousand. The catch is complexity. You are essentially building an interpreter. You now have to validate user input stored in JSON, handle malformed rule configurations, manage versioning, and protect against infinite loops or overly broad rules that accidentally block legitimate business operations.

Configuration-driven systems can easily devolve into badly designed mini programming languages. Keep your rule types focused and specific. Do not try to build a full general-purpose scripting engine inside your database columns.

Caching and Performance

When rule execution relies on database-backed configuration, performance becomes a concern. Hitting the database for rule definitions on every single incoming request adds unnecessary latency and puts immense pressure on connection pools.

An active enterprise platform can process hundreds of requests per second. To prevent database exhaustion, rules should be cached using distributed stores like Redis or in-memory caches like Caffeine with proper cache eviction triggers whenever an administrator updates a policy.

Multi-Tenancy and Isolation

In a multi-tenant environment, data leakage is an existential threat. The organization_id must flow unalterably from the incoming request context, through the repository layer, into the cache keys, and down to the rule evaluator. Tenant boundaries must be strictly enforced at every query level.

Execution Semantics and Auditability

Decide early how your engine handles failures. Do you stop at the first failed rule or collect all violations in a single response so employees can fix them at once? Furthermore, financial systems live under strict regulatory scrutiny. When an internal auditor asks why an expense request was rejected, stating that a rule failed is unacceptable. Your system must capture deep contextual metadata, including rule IDs, active versions, evaluation timestamps, and configuration snapshots.

Practical Engineering Takeaways

Start simple. Do not build a massive rules engine on day one if your company only has three static policies managed by the engineering team.

Begin with the clean Java interface approach. It provides immediate decoupling and keeps your validation logic organized without operational overhead. As your business grows, as non-technical stakeholders demand autonomy, and as multi-tenant requirements multiply, migrate the rule definitions into storage while keeping the evaluator registry pattern intact.

Good system design is rarely about picking the most advanced pattern available. It is about matching your architectural complexity directly to the actual velocity and demands of the business.