Development

Beyond AI: Architecting Software That Learns Your Business Rules

Beyond AI: Architecting Software That Learns Your Business Rules

Most software does not need to become “intelligent” before it becomes valuable. It needs to become reliable at the decisions that already define the business: which orders can ship, which discounts may stack, when an account needs review, and who may approve an exception.

These rules often begin life scattered across controllers, database queries, spreadsheet formulas, support-team knowledge, and urgent production patches. The application may work, but every new feature turns into an archaeological exercise. The real architectural challenge is not adding AI. It is building software that can express, apply, change, and explain business rules without making the codebase brittle.

Business rules are a first-class design concern

A business rule is a statement that determines an outcome in a domain. “Orders over a credit limit require approval” is a rule. “A customer can receive free shipping only in eligible regions” is a rule. “Invoices may be cancelled only before settlement” is a rule.

Rules are not the same as technical validation. Checking whether an email is syntactically valid is a technical concern. Checking whether that email belongs to an approved corporate domain for a contract tier is business logic. Mixing the two produces code that is difficult to test and even harder to change safely.

The first useful distinction is between stable domain concepts and changeable policy. An Order, an Invoice, and a Customer are durable concepts. The threshold at which an order requires approval is policy. Give each a place in the design.

Keep rules out of transport layers

In a PHP application, controllers should translate HTTP requests into application actions. They should not decide whether a transaction is permitted. Likewise, an ORM model is usually the wrong place for a growing collection of cross-cutting policy decisions.

A small application service can coordinate the work while a focused policy object owns the decision:

final class CreditApprovalPolicy
{
    public function requiresApproval(Order $order, Customer $customer): bool
    {
        return $order->totalAmount()->isGreaterThan(
            $customer->creditLimit()
        );
    }
}

final class PlaceOrder
{
    public function __construct(
        private CreditApprovalPolicy $approvalPolicy,
        private OrderRepository $orders
    ) {
    }

    public function handle(Order $order, Customer $customer): void
    {
        if ($this->approvalPolicy->requiresApproval($order, $customer)) {
            $order->markPendingApproval();
        } else {
            $order->confirm();
        }

        $this->orders->save($order);
    }
}

This is not ceremony for its own sake. The decision is named, independently testable, and reusable from an API endpoint, a queue worker, an import process, or a command-line job. The application service describes the workflow; the policy describes the rule.

Model the language people actually use

Maintainable business software benefits from a shared vocabulary. If finance says “settlement,” do not name the state completed in one service, paid in another, and finalized in a third unless those terms truly mean different things.

Good names reduce translation errors. They also expose ambiguity early. A method named canCancel() invites the team to define what cancellation means. Does it include a refund? Is it allowed after shipment? Does a pending bank transfer change the answer? Those questions are product decisions, and the architecture should make them visible rather than hiding them in nested conditionals.

For consequential workflows, model state transitions explicitly. A boolean such as is_approved is insufficient when the real lifecycle includes submitted, under review, approved, rejected, expired, and revoked. Clear states make invalid transitions harder to introduce accidentally.

Use data for policy, code for meaning

Teams often swing between two unhelpful extremes: hard-coding every threshold, or putting the entire domain into a generic rules engine. A pragmatic boundary works better.

Store values that business users reasonably need to adjust as data: approval limits, eligible countries, effective dates, product-category mappings, and feature entitlements. Keep domain meaning and complex behavior in code: how amounts are compared, how rules interact, how a transition is performed, and what happens when required policy is missing.

For example, a versioned policy table can hold an approval threshold with a validity period. The code still decides that exactly one active policy must be selected and that a missing policy blocks the operation rather than silently approving it.

  • Use database constraints for invariants that must always hold, such as non-negative quantities and unique external references.
  • Use transactions when a rule depends on multiple writes succeeding together.
  • Use application-level policies for decisions requiring domain context.
  • Record the policy version or inputs used for material decisions when later explanation matters.

This division prevents configuration from becoming an untyped programming language while still allowing controlled policy change.

Make decisions explainable

A boolean answer is rarely enough. Operations teams, customers, and future developers need to know why a request was denied or routed for review. The goal is not to expose internal implementation details; it is to retain meaningful decision context.

Instead of returning only false, return a small result object with a status and reason code. The API can map that result to a stable response, while logs and audit records preserve the relevant identifiers and policy version.

final class EligibilityResult
{
    public function __construct(
        public readonly bool $eligible,
        public readonly string $reason
    ) {
    }
}

// Example reasons: "region_not_supported", "account_overdue"

Reason codes are preferable to copying human-facing prose through every layer. They remain stable enough for clients and reporting, while presentation can be localized or changed without changing the underlying rule contract.

Design APIs around outcomes, not database tables

An API that merely mirrors persistence leaks internal structure and invites clients to recreate business logic. If clients must inspect five fields to determine whether an invoice can be cancelled, the rule will drift across web applications, integrations, and mobile clients.

Expose meaningful actions and outcomes instead. An endpoint such as POST /invoices/{id}/cancellation-requests can represent an intentional business operation. Its response can communicate whether the request was accepted, rejected, or requires review.

Idempotency matters whenever retries are possible. A network timeout does not tell a caller whether the server processed the request. For externally initiated commands, accept an idempotency key, persist it with the resulting outcome, and return the original outcome on a valid retry. This protects the rule from being applied twice because infrastructure behaved normally.

Test the rule at the right level

Business rules deserve direct tests with readable examples. A test named order_over_credit_limit_requires_approval is a compact piece of executable policy documentation. It is more valuable than a controller test that buries the same condition under request setup and authentication concerns.

Also test boundaries: the amount exactly at the limit, the first moment a policy becomes effective, an expired entitlement, and competing conditions that may produce different outcomes. Integration tests should then confirm that persistence, transactions, and API mapping preserve the intended behavior.

When rules become complicated, use tables of examples rather than clever test helpers. A future maintainer should be able to see the cases the business considered important without reverse-engineering an abstraction.

Change rules like production software

Policy changes can have larger consequences than code changes because they alter live decisions immediately. Treat them accordingly. Validate new configurations before activation, preserve prior versions, define effective dates carefully, and provide a rollback path. If a policy change affects existing records, decide explicitly whether it applies only to new decisions or triggers a controlled re-evaluation.

Observability is part of the architecture. Track failed transitions, rejected commands, missing configuration, and unexpected rule combinations. Avoid logging sensitive values unnecessarily, but retain enough context to diagnose a decision without replaying an entire production incident from memory.

The durable alternative to magical software

Systems earn trust when their decisions are predictable, inspectable, and adaptable. That does not make them less sophisticated. It makes their sophistication useful.

Start by locating the rules currently hidden in controllers, queries, and tribal knowledge. Name them. Give them focused homes. Persist changeable policy with versioning. Return explanations, design retry-safe commands, and test the boundary cases people will eventually encounter.

Software that learns your business rules is not software that guesses. It is software whose structure allows the business to teach it clearly—and allows engineers to keep that teaching correct as the business evolves.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.