Published on

A Price Is a Decision, Not a Field

Authors

Go ahead and ask three systems the price of a cinema ticket, and then you get three honest answers.

For example, take Nordlys Bio (a fictional chain with dozens of cinemas across Denmark), and ask what a seat costs for the 19:30 show. Ticketing says 125 kr. Then there is a membership system which gives members 85%, and a campaign tool has Tuesdays at 79 kroner in Aarhus this month. Then there is the manager in Aalborg, who has typed 40 kroner into an override for one school screening next Thursday. Each one of these is correct in the context it was entered.

An integration has to put one number in the storefront. The typical way is to pick a master, copy the value across and patch the exceptions.

I built it that way initially. Ticketing became the master, and the member discount and the campaigns went in as exceptions on top. It worked for about a month. Then it became horrific.

The campaign team began entering the last day of a campaign instead of the day after. Campaign prices ran a day too long, collided with overrides, and the exceptions started winning in the wrong order. Each of the systems was doing its job. I had put the decision in the gaps between them, where nobody looks.

A master system settles where a value is stored. It doesn't settle which value is right for this ticket, in this market, for this customer, today. That is a decision, and it is made every time a price is read.

An integration is a wrong place to take that decision. It understands the value, but not the business. It ended up taking decisions by accident, and they were wrong. The rule belongs in domain code, where someone can review it.

I did not start with three properties. I started with a precedence list and kept adding exceptions to it. Each time I had to explain why one value was beating another, I reached for one of the same three words.

Intent, coverage, freshness

Intent means: "Is the value deliberate?" The manager in Aalborg sits down and types 40 kr for one screening. That number I trust more than any other. The standard 125 kr list price is computed from a seat category and time band. Nobody picked that for this screening.

Coverage: The override covers one screening. The Tuesday campaign covers all screenings in one city for one month. The list price covers everything, everywhere. Coverage does not mean intent. A chain-wide campaign can be as deliberate as a local one, but says nothing about this specific screening. I use coverage to choose candidates that are equally deliberate, where the narrow one wins.

Freshness is when the value was last confirmed to be true. A live feed from ticketing is fresh. The campaign spreadsheet was last touched when the campaign was set up, and nobody has looked on it since.

Together they give an order. Anything that is expired or stale is thrown out first. The more deliberate value wins, and between two equally deliberate values the narrower one wins.

When a field is blank

Example: An override row exists for a school screening. It has no amount (blank). Does this mean "no override", or does it mean "free"? A member could ask for a price on a premiere, but it has no list price yet. So the membership discount of 85% of nothing is nothing. A movie plays in Aarhus, and not in Aalborg. A member from Aalborg is buying a ticket in Aarhus. Which city's campaign then applies?

These are not technical problems. They are business questions that always existed. A department answering its own version quietly. The temptation is always to make a sensible guess. Write ?? 0 or ?? listPrice and move on. This becomes a policy the moment it ships.

I try to turn each gap into a named rule. A really boring one with a name that a product owner can read out loud. "Blank override means no override." "An explicit zero is a free ticket." "A derived price with a missing input is not a price." "A screening with no candidate in its own market is not for sale." If a named rule is wrong, then someone can find it and change it. This is an option the null check never gave anyone.

Return the decision, not just the number

A member of the support staff at Nordlys Bio once asked me a question: why a customer had paid 79 kroner for a ticket on a Wednesday, when the campaign only runs Tuesdays. I could not explain it. That was the day I stopped returning prices from the resolver.

What I want back is the value, the rule that produced it, the candidates it considered in order, the ones it rejected and why, and the sources that did not answer.

// Ranks are explicit, so the policy does not depend on declaration order.
public enum Intent { Default = 0, Campaign = 10, Override = 20 }
public enum Coverage { Screening = 0, Market = 1, Chain = 2 } // lower is narrower

public sealed record PriceCandidate(
    string Source,
    decimal? Amount,
    Intent Intent,
    Coverage Coverage,
    DateTimeOffset ObservedAt,
    DateTimeOffset? ValidUntil = null);

public sealed record Rejected(PriceCandidate Candidate, string Reason);

public sealed record PriceResolution(
    decimal? Amount,
    string Rule,
    IReadOnlyList<PriceCandidate> Evidence, // every usable candidate, winner first
    IReadOnlyList<Rejected> Rejected,
    IReadOnlyList<string> FailedSources)
{
    public bool IsSellable => Amount is not null;
}

public static PriceResolution Resolve(
    IReadOnlyList<PriceCandidate> candidates,
    IReadOnlySet<string> failedSources, // sources that should have answered for this screening
    DateTimeOffset now,
    TimeSpan maxAge)
{
    var rejected = new List<Rejected>();
    var usable = new List<PriceCandidate>();

    foreach (var c in candidates)
    {
        switch (c)
        {
            case { Amount: null }:
                rejected.Add(new(c, "no amount"));
                break;
            case { Amount: < 0 }:
                rejected.Add(new(c, "negative amount"));
                break;
            case { ValidUntil: { } end } when end < now:
                rejected.Add(new(c, "expired"));
                break;
            case var _ when now - c.ObservedAt > maxAge:
                rejected.Add(new(c, "stale"));
                break;
            default:
                usable.Add(c);
                break;
        }
    }

    var ranked = usable
        .OrderByDescending(c => c.Intent)
        .ThenBy(c => c.Coverage)
        .ThenByDescending(c => c.ObservedAt)
        .ToList();

    var best = ranked.FirstOrDefault();

    var rule = best switch
    {
        null => "not-for-sale",
        { Intent: Intent.Default } when failedSources.Count > 0 => "fallback-source-failed",
        { } b => $"prefer-{b.Intent}".ToLowerInvariant(),
    };

    return new(best?.Amount, rule, ranked, rejected, [.. failedSources]);
}

So the resolver returns a PriceResolution and not a decimal. Now I have the answer in the resolution: the rule, the candidates in order, and the ones that lost.

The override, the unit and the feed

The override that outlived its purpose. The summer discount was created as an override in June. In fall it is still there. The overrides are high intent and typically nothing ever outranks them. Lowering the priority of the overrides would not have helped. They absolutely need an end date of their own. Because a value that was entered in June says nothing about October. Freshness does not cover this either, since the row may have been touched yesterday and still belong to a campaign that ended in August.

The derived value with a unit or rounding error. The membership factor arrives from one system as 85 and from another as 0.85. Or the list price arrives in øre, so 125 kroner is 12,500, and somewhere that number is read as kroner. A ticket at 12,500 kroner gets noticed. The quiet version is the member price. 85 percent of 12,500 øre is 10,625 øre, which is 106.25 kroner. If one side rounds that to whole kroner and the other keeps the øre, they disagree on every member ticket, and it takes a quarter before anyone notices. The defence is to make the unit part of the type, and to round in exactly one place, by a rule with a name.

The fallback that went live when a feed failed. The campaign feed failed one night. Nobody noticed, because the resolver still had a perfectly valid list price to return. Tuesday customers in Aarhus saw 125 kroner instead of 79. From the storefront's side nothing was broken: no error, no alert, just a valid price that was not the advertised one. The system did the reasonable thing, and the reasonable thing was wrong.

Testing the decision space

Since the rules are named, the test suite can be a table instead of arrange-act-assert methods. The sample leaves out applicability. The caller decides which sources were supposed to answer and passes in only the relevant failures, so that part of the policy lives outside the resolver.

OverrideCampaignListExpected ruleSellable
40, fresh79, fresh125prefer-overrideyes
0, fresh79, fresh125prefer-overrideyes
blank79, fresh125prefer-campaignyes
40, stale79, fresh125prefer-campaignyes
40, ended79, fresh125prefer-campaignyes
nonenone125prefer-defaultyes
nonesource failed125fallback-source-failedyes
nonenonenonenot-for-saleno

Final thought

I still catch myself typing ?? listPrice. It is a typical but dangerous way of finishing a method, and it is a pricing decision that nobody will ever review. When two systems disagree about a price, the integration is going to make that decision either way. I try to catch myself in the act these days, and write the rule down when I still remember why.