Learning Scala: Naming the Business Rule Your Tests Miss
There is a system with four thousand tests, all green. They have been green for months. CI is fast, the team trusts it, deploys go out daily, and nobody is embarrassed to say the platform is well covered. Yet it just shipped a refund larger than the original charge.
That sounds like a testing failure, and in the shallow sense it is. Somewhere there should have been a test that caught it. Somewhere there should have been a case, an assertion, a fixture, a fake payment, a mocked refund, a regression test with the right numbers in the right columns. But that is not the interesting failure. The interesting failure is that the rule was never a thing the system could point at.
A refund cannot exceed the settled charge. Simple sentence. Obvious sentence. The kind of sentence everyone in the room agrees with as soon as the incident review begins. And yet, before the incident, where did that sentence live? A little of it lived in the refund service. A little of it lived in an admin form. A little of it lived in a database constraint. A little of it lived in the payment gateway integration. A little of it lived in the memory of the person who last fixed the related bug. To test the rule, you had to drive the whole platform from the outside and hope the dragon wandered into view.
You must name the dragon before you slay it. A test suite cannot prove a business rule the model never named. It can test around the rule, through the rule, beside the rule. It can prove that endpoints return 200, rows land in tables, messages enqueue, and services call other services in the expected order. It can prove the plumbing connects, and in a distributed commerce platform, plumbing matters. But plumbing is not policy. The bug did not ship because the team had no tests. It shipped because the business rule was not a named part of the model, and testing inherits the shape of the model it is given.
Catch up on earlier posts to follow along with the Functional Programming Isn’t Just for Academics series:
- Post 1: Why Functional Programming Matters for the Systems We Build Today Post 2: Immutability by Default and the Foundation of Reliable Systems Post 3: Pure Functions: Your First Step Toward Bug-Free Concurrency
Each post in this series explores how teams use Scala to build applications that stay clean, testable, and easy to scale.
Domain-Driven Design Names the Dragon
This is where Domain-Driven Design earns its keep. Not as a set of ceremonies or bounded context diagrams nobody updates after the workshop, rotting in Confluence or wherever you hide your dead assets. DDD matters here because it asks the first useful question: what is the business actually deciding?
A refund is not merely an HTTP request, a row update, or a call to a payment gateway. It is a decision made under policy. Given what was charged, what was settled, what was already refunded, what was returned, what was promised, who is asking, and what the current policy allows, what should the platform do? Approve the refund. Deny it. Cap it. Route it for review. Ask for more information. Reverse a prior decision. Issue store credit instead of cash. Those are domain outcomes, and until the model can say them, the tests cannot really ask for them. So name the operation.
That one line changes the shape of the problem. A refund cannot exceed the settled charge is no longer folklore smeared across three services. It is a claim about authorizeRefund. A test can call it. A reviewer can read it. A product owner can argue about its outcomes. A property can try to break it. The rule now has a place to live.
That is the difference between testing behavior and testing architecture by accident. If the model says refund decision, the tests can test refund decisions. If the model says controller calls service calls repository calls gateway, the tests will test calls. The first dragon is not the bug. The first dragon is the missing name.
Behavior-Driven Development Describes the Dragon
Once the decision has a name, examples become useful in a different way. A behavior example is not a random test case. It is a small business story with the ambiguity squeezed out of it. Given a settled charge of $100, and no prior refunds, when a customer requests a refund of $120, what should happen?
Maybe the request is denied. Maybe it is capped at $100. Maybe it is routed for review because customer support has authority to override. Those are different businesses. They may all be defensible. The point is not that one answer is universal. The point is that the answer must be chosen, and Behavior-Driven Development, at its best, forces that choice into the open.
That scenario is not enough by itself. It is one example, not a proof. But it does something valuable before the code gets clever: it makes the business say what the rule means. Should the result be denied? Should it be capped? Should it be routed for review? Each answer implies a different policy, a different customer experience, and a different operational burden. The test is useful because it makes the disagreement visible before the payment gateway, the admin screen, the database, and the incident review are involved.
That is where many teams lose the plot. They think BDD is a syntax. It is not. The Given, When, Then form is the least interesting part. The interesting part is that a rule which used to live as an assumption has become a concrete example people can inspect. BDD does not slay the dragon. It draws the dragon clearly enough that everyone can agree what they are looking at.
Test-Driven Development Gives You a Blade
Once the rule has a name and the examples have shape, Test-Driven Development becomes less theatrical. You do not need to stand up the whole commerce platform to ask whether a refund decision is sane. You do not need a real database, a real payment gateway, a real queue, and a real admin user clicking through a real screen. You need the decision.
That is a small test because the model has made the rule small enough to hold. The important thing is not that the test came first in some ritual sense. The important thing is that the test applies pressure directly to the decision that owns the rule. It does not poke the API and infer the decision from side effects. It does not inspect three tables and a queue message and reconstruct what must have happened. It calls the named thing.
This is where functional programming helps, because it pushes the decision into a shape that can be called without dragging the world behind it. If authorizeRefund is mostly a pure function over explicit inputs, then testing it is cheap. If it reaches into the database, reads the clock, asks the gateway, checks global configuration, and mutates state while deciding, then testing it requires a stage play. The more ambient the decision, the more theatrical the test. The more explicit the decision, the more precise the test.
TDD is not magic. It cannot rescue a model that has no place for the rule. But once DDD has named the decision and BDD has clarified the expected behavior, TDD gives the developer a tight loop for growing the rule without losing sight of it. Now that you know it is a dragon, and something of its nature, you can actually swing at it.
Property Based Testing Hunts the Dragon Where It Hides
Example tests are necessary, but examples are limited by imagination. Someone has to think of $120 against $100. Someone has to think of exactly equal refunds, or of prior partial refunds. Someone has to think of zero, rounding, currency precision, multiple captures, store credit, tax inclusive refunds, split tenders, gift cards, exchanges, failed reversals, and the devilish edge case where the first refund was approved but not yet settled. A handwritten test checks the cases the author remembered. A property based test checks the shape of the rule.
That property is not interested in one example. It is interested in an invariant: an approved refund must never exceed the remaining refundable amount. That sentence is the dragon in a more dangerous form. It does not say, remember the $120 case. It says, try to violate this rule thousands of ways.
This is where the FP discipline pays again. Property based testing wants mostly pure decisions with explicit inputs and typed outputs. It wants generators. It wants values. It wants a function it can call again and again without accidentally charging a card, changing a database, sending an email, or depending on what time the test happened to run. If the decision is explicit, the generator can attack it. If the decision is smeared across infrastructure, the generator has nowhere to aim.
That does not make example tests obsolete. The BDD examples still matter because they carry business meaning. They explain the policy in a way people can discuss. Property tests do something else. They patrol the territory between examples. They ask whether the rule survives the cases nobody wrote down. BDD says, here is what we mean. Property based testing asks, are you sure?
Specification Is Not a Document Beside the System
This is where the acronyms can become a distraction, so put them back in their place. DDD, BDD, TDD, and property based testing are not four competing religions but four pressures on the same problem. DDD gives the rule a name. BDD gives the rule examples. TDD gives the rule an implementation path. Property based testing gives the rule an adversary.
Together, they turn an assumption into an executable specification. Not a specification in the sense of a PDF somebody signs and nobody reads. A living specification, named in the model, explained by examples, exercised by tests, and challenged by generated cases. The specification is not beside the system. It is not downstream from the system. It is the system learning to say what it means.
Functional programming fits because it gives that specification a hospitable shape. Algebraic data types let outcomes be explicit. Pure functions make decisions cheap to call. Effects at the edge keep tests from becoming distributed system rehearsals. Typed errors make failure part of the model instead of an exception path nobody remembers to check. Immutability makes generated cases safer to explore. Explicit inputs make hidden dependencies visible.
FP does not decide the refund policy for you. It does not know whether the business wants denial, capping, review, store credit, or escalation. It does not replace product judgment, domain language, or customer empathy. It gives those things somewhere durable to live. That is the practical value. The business rule stops being a rumor carried by infrastructure and becomes a thing the system can say.
Simulation Asks Before Authority Acts
There is one more step, and it matters more as systems become more automated. Once a decision is named, typed, and mostly separated from its effects, you can ask the system what it would do without allowing it to do anything yet. That is simulation.
A production path authorizes the refund and may eventually call the payment gateway. A simulation path evaluates the same request under the same rules and returns the decision, the reasons, and the intended effects. Nothing is charged. No inventory moves. No email is sent. No order is committed. The platform has answered the question before the world has been changed.
A production interpreter performs the effects. A simulation interpreter records the effects it would have performed. That distinction becomes critical when the caller is no longer a human clicking a button. An automated reorder job, a customer service assistant, a marketplace agent, or a procurement workflow should not learn the refund policy by being given authority and watched nervously. It should be able to ask, what would happen if I requested this, and receive the same decision production would make, without money moving.
This is not a mock. A mock imitates a dependency. Simulation interprets the same decision under a different authority. It lets the platform answer before it acts. What would this refund do? What would this purchase commit? What would this promotion apply? What would this agent be allowed to approve? What would happen if the contract expired yesterday, the buyer is over budget, the shipment was partially returned, and the payment was split across a gift card and a credit card?
Those are not questions you want answered for the first time in production. They are also not questions you want trapped in a QA environment that only knows three clean scenarios and a fake Visa number. If the business decision is named, the simulation can ask it. If the business decision is hidden inside effects, simulation becomes theater too.
A Green Test Suite Should Mean Something
A green test suite should mean the business rules hold. In many systems it means the plumbing connects. Those are not the same claim.
A test that proves the refund endpoint returns 200, the refund row is written, and the message is published may be useful. But none of those tests prove that the platform cannot approve a refund larger than the remaining refundable amount. Only a test aimed at that rule can prove that rule, and only a model that named the rule gives the test somewhere to aim.
That is the heart of it. Testing is not merely a phase after design. Testing is a consequence of design. The tests you can write are shaped by the model you built. If the model names workflows, you can test workflows. If the model names database changes, you can test database changes. If the model names business decisions, you can test business decisions.
The refund bug is not special. Commerce systems are full of unnamed dragons. A customer cannot use an expired contract price. A buyer cannot exceed their delegated authority. A promotion cannot apply twice unless the policy says it can. A shipment cannot be refunded before it is paid for. A tax exempt purchase cannot lose its exemption halfway through fulfillment because one service forgot the certificate. A return window cannot mean thirty days in one system and thirty midnights in another.
Every one of those rules can become either a named decision or a rumor. If it is a named decision, DDD can locate it, BDD can explain it, TDD can grow it, property based testing can attack it, and simulation can ask what it would do before authority is granted. If it remains a rumor, the best test suite in the world can only hope to stumble into it.
Four thousand green tests did not miss the refund bug because the team forgot how to test. They missed it because the model never named the dragon. Name it. Then slay it.
For more on structuring the types that make decisions like this explicit in the first place, see Modeling Money Types and Clean Scala Code Principles for Real Systems.
This is Part 19 in an ongoing series, covering how DDD, BDD, TDD, and property based testing turn an unnamed business rule into an executable specification, following Part 18's look at making time an explicit argument instead of a hidden side effect in commerce code. Read "Time Is an Argument, Not a Side Effect"
Frequently Asked Questions
Why can a fully green test suite still miss a real business rule?
A green test suite proves that plumbing works, not that a business rule holds everywhere it applies. If a rule like "a refund cannot exceed the settled charge" is scattered across a form, a database constraint, and a service integration instead of named in one place, no amount of passing tests can prove it holds consistently.
What is the difference between DDD, BDD, TDD, and property based testing?
Domain-Driven Design names a business rule in the code, such as an authorizeRefund function returning a typed RefundDecision. Behavior-Driven Development turns that rule into concrete Given, When, Then examples. Test-Driven Development grows the implementation from those examples in a tight loop. Property based testing generates thousands of inputs to check that the underlying invariant never breaks.
How does functional programming make business rules easier to test?
Functional programming keeps a decision separate from side effects like database calls or clock reads. A pure function with explicit inputs and a typed output can be tested directly, without standing up a database, a queue, or a payment gateway. The more explicit the inputs, the cheaper and more precise the test.
What is an executable specification?
An executable specification is a business rule that lives in running code instead of a document nobody reads. It is named by DDD, explained with examples by BDD, grown through a feedback loop by TDD, and stress tested by property based testing, so it stays accurate as the system changes.
What is simulation and how is it different from mocking?
A mock imitates a dependency so a test can run without a real database or service. Simulation evaluates a real business decision under production rules and returns what the system would do, without performing the effect, which matters once automated agents start asking what they are allowed to do.
Why does property based testing matter more than handwritten examples alone?
A handwritten test only checks the cases the author thought of. A property based test checks an invariant, like an approved refund never exceeding the remaining refundable amount, against thousands of generated inputs including edge cases nobody wrote down by hand. It complements example tests rather than replacing them.