order process part 2: refining the domain model and building an anti-corruption layer

order process part 2: refining the domain model and building an anti-corruption layer
Photo by Ryan Loughlin / Unsplash

Last time, we created a testable implementation of a simple function, that takes an order, computes some values like net / gross totals and tax, and returns the result so we can print it. Here is part 1 if you're interested.

As a refresher, here is what that program looked like:

<?php

function stringToOrder(string $input): array
{
    $lines = explode("\n", $orderString);
    $order = [];
    foreach ($lines as $line) {
        $parts = array_map('trim', explode(',', $line));
 
        $name = $parts[0];
        $price = (float)$parts[1];
        $amount = (int)$parts[2];
        $order[] = [$name, $price, $amount];
    }

    return $order;
}

function generateReceipt(array $order, float $taxRate): string
{
    $grossTotal = 0;
    $taxTotal = 0;
    $receipt = "";

    foreach ($order as $item) {
        // Destructure array to get easy access to each variable
        [$name, $price, $amount] = $item;

        // Calculate totals and taxes for the current item
        $netTotal = $price * $amount;
        $tax = $netTotal * $taxRate;
        $gross = $netTotal + $tax;

        // Update sum totals
        $grossTotal += $gross;
        $taxTotal += $tax;

        // Append the receipt line with proper formatting
        $receipt .= sprintf(
            "You ordered %d x %s. That makes \$%.2f (incl. \$%.2f tax)\n",
            $amount,
            $name,
            $gross,
            $tax
        );
    }

    // Append totals with proper formatting
    $receipt .= sprintf(
        "The total is \$%.2f (incl. \$%.2f tax)\n",
        $grossTotal,
        $taxTotal
    );

    return $receipt;
}

function getInput(): array
{
    if (mt_rand(0, 9) === 0) {
        throw new RuntimeException("Could not fetch order");
    }
    
    return <<<TEXT
        Hamburger, 6.95, 2
        Cheeseburger, 7.95, 2
        Fries, 3.25, 3
        TEXT;
}

function getTaxRate(): float
{
    if (mt_rand(0, 9) === 0) {
        throw new RuntimeException("Could not fetch taxRate");
    }
    
    return 0.08;
}

try {
    $input = getInput();            // impure, can fail
    $taxRate = getTaxRate();        // impure, can fail
    $order = stringToOrder($input); // impure, can fail
    
    // if we reached this point, we now have a valid $order
    // and can perform pure operations without side effects πŸ™‚
    
    $receipt = generateReceipt($order, $taxRate);        // pure
} catch (RuntimeException $e) {
    $receipt = "An error occurred: " . $e->getMessage(); // pure
}

echo $receipt; // impure

To write a test, we simply call our generateReceipt() function with an $order and a $taxRate and check programatically if the output string matches our expectation. Great!

New Requirements

Now, our product manager gave us some new requirements.

Input from Machines

The on-premise machines which customers use to create orders send them in the format we know.

Hamburger, 6.95, 2
Cheeseburger, 7.95, 2
Fries, 3.25, 3

Luckily, the machines send us web requests, albeit in an awkward format, and they expect us to simply return the response string. So our current implementation using echo works just fine for that.

Input from Mobile Requests

Additionally, there is a mobile app which can contact our backend via a REST api sending JSON requests. A request looks like this:

[
  {"name": "Hamburger", "price": 6.95, amount: 2},
  {"name": "Cheeseburger", "price": 7.95, amount: 2},
  {"name": "Fries", "price": 3.25, amount: 3}
]

When we get such a request, we should respond with JSON. The mobile app has its own rendering logic with translations, HTML, and so on. Our response structure:

[
  "items": [
      {"name": "...", "price": 6.95, amount: 2, "netTotal": 13.9, "tax": ...},
      ...
  ],
  "aggregate": {
    "grossTotal": ...,
    "tax": ...
  }
]

How not to do it

Here are some bad ideas to look out for when trying to implement this:

  • add more parameters to generateReceipt(). Within the function, add a bunch of if-elses to decide how to build the internal string
  • Or: keep generateReceipt() as it is, returning a receipt string. Then write a regular expression to parse out the individual gross totals and taxes for each order item, and then calculate the net totals backwards and build your JSON from there
  • Add a new $isJsonRequest boolean flag to the stringToOrder() method, and conditionally parse either the raw text version or the json version.

Why these are bad ideas:

  1. requires reverse-engineering within your own codebase
  2. contaminates your functions that have one concern with other concerns
  3. creates a confused mess

Where does the confusion come from?

In a case like this, where you are confused how to proceed, a good base assumption is this:

Our current domain model is not modular enough to adjust to these requirements.

Fixing the core

So, before jumping right into adding if/elses or parameters to random places, we will actually go and think (loudly, on paper, and ideally with team mates) about our core logic and its modules. Let's completely forget about code for a second and write down what our application should be able to do. Define well-named states, state transitions and transformations for everything. Also think about what is reusable core logic, and what is client specific. Then the rest will be obvious.

Let's start with our domain objects, all the "stuff" the can "exist" within our system and that we could put a label on.

Raw Text Input: client specific
JSON Input: client specific

Order object, has many OrderItems
OrderItem
ComputedOrderItem
ComputedOrder, has many ComputedOrderItems

Receipt output: client specific
JSON output: client specific

Now, let's define the actions in our system, that transform data all the way from input to output. Imagine input parameters on the left and output types on the right.

raw text input -> rawToOrder -> Order
json input -> jsonToOrder -> Order

Order -> toComputedOrder -> ComputedOrder
OrderItem -> calculateItemTaxAndTotals -> ComputedOrderItem

ComputedOrder -> receipt output
ComputedOrder -> json output

The names are not very great yet, and it's still a bit confusing how ComputedOrder and ComputedOrderItem and their transformations fit together. But at this point a pattern emerges. We have a core in the middle, that should be able to work with any input and any output.

We need to protect this core. Let's clear that up right away, and adjust our diagram.

raw text input -> rawToOrder -> Order
json input -> jsonToOrder -> Order

ANTI CORRUPTION BARRIER

Order -> toComputedOrder -> ComputedOrder
OrderItem -> calculateItemTaxAndTotals -> ComputedOrderItem

ANTI CORRUPTION BARRIER

ComputedOrder -> toReceipt -> receipt string
ComputedOrder -> toMobileJson -> json string

Note that we added two anti corruption barriers, one at the input side and one at the output side.

How is this diagram different from our current source code?

  • The current generateReceipt() function returns a string. Instead we want to return flexible and strongly typed data that can be further processed
  • Note that we will completely remove the code that deals with data representation. We are reducing the function from having multiple responsibilities (calculating some new values and building a client representation) to just one responsibility.
  • For the input, we already outsourced the parsing task to an outside function that gives us an Order. This is great! Now we can just write a second transformer from json input to Order.

Embody the domain logic

Now that we have our diagram, let's embody it with our code. While writing out this domain model we can think more about how exactly to implement everything.

Here are our domain objects

readonly class Item
{
    public function __construct(
        public string $name,
        public float  $price,
        public int    $amount,
    )
    {
    }
}

readonly class Order
{
    /** @param array<Item> $items */
    public function __construct(public array $items)
    {
    }
}

readonly class AggregatedItem
{
    public function __construct(
        public Item  $original,
        public float $netTotal,
        public float $tax,
    )
    {
    }
}

readonly class AggregatedOrder
{
    /** @param array<AggregatedItem> $items */
    public function __construct(
        public array $items,
        public float $grossTotal,
        public float $taxTotal,
    )
    {
    }
}

// rawTextInput: string
// jsonInput: string

// rawTextOutput: string
// jsonOutput: string

I also drew a map of these types. They are all the types we need to model our program.

Inputs on the left, domain objects in the middle and outputs on the right.

Note that ALL of the types are immutable. We create an AggregatedItem by taking an Item and wrapping it together with some computed values. The result is a new type and a new object. An AggregatedOrder is simply a wrapper containing all the AggregatedItems, as well as its own computed values "tax" and "grossTotal".

Now, let's add our functions.

πŸ’‘
I already wrote down all the Types we expect in the system. Now, when I create function signatures that have a clear input type, output type and communicate intent, LLMs like ChatGPT 4o have an incredibly easy time generating the implementations. I ended up optimizing each function manually, but Copilot was able to come up with immediate and correct solutions. The same is true for developers: We already built a mental model of the processes, now the implementation is trivial and easy to optimize.

Turn a raw string into an Order:

function rawStringToOrder(string $input): Order
{
    $lines = explode(PHP_EOL, $input);

    $lineToItem = function (string $line) {
        $parts = array_map('trim', explode(',', $line));

        $name   = $parts[0];
        $price  = (float)$parts[1];
        $amount = (int)$parts[2];

        return new Item(name: $name, price: $price, amount: $amount);
    };

    $items = array_map(callback: $lineToItem, array: $lines);

    return new Order($items);
}

Here we first transform the input string into an array of lines. (By the way, this can break if the remote system uses a different line ending than our PHP server. So there is room to optimize here). Then we define a helper function $lineToItem which knows how to create an Item (aka OrderItem) from a line. This process, once again may fail if the input does not have the correct format. We are not explicitly validating this here, but the fact the the input is unreliable is inherent and cannot be "fixed". We apply our $lineToItem function to each line and ultimately return an Order (domain object) with the resulting Items.

Turn Json string into an Order:

function jsonStringToOrder(string $input): Order
{
    $data = json_decode($input, true);

    $jsonItemToOrderItem = function (array $item) {
        return new Item(
            name: $item['name'],
            price: $item['price'],
            amount: $item['amount'],
        );
    };

    $items = array_map(callback: $jsonItemToOrderItem, array: $data['items']);

    return new Order($items);
}

Same idea as above, except that the input string has a different format.

Turn Item into AggregatedItem (with total and tax):

function processItem(Item $item, float $taxRate): AggregatedItem
{
    $netTotal = $item->price * $item->amount;
    $tax      = $netTotal * $taxRate;

    return new AggregatedItem($item, $netTotal, $tax);
}

Here we perform the necessary (albeit simplistic) calculations to create an AggregatedItem. Note that I decided to keep a reference to the original item as well, so that I don't have to copy all the original values like name, price and amount and can still refer to them when I need to.

Turn Order into AggregatedOrder:

function processOrder(Order $order, float $taxRate): AggregatedOrder
{
    $aggregatedItems = array_map(
        callback: fn(Item $item): AggregatedItem => processItem($item, $taxRate),
        array: $order->items
    );

    $netSum = array_reduce(
        array: $aggregatedItems,
        callback: fn($carry, $item): float => $carry + $item->netTotal,
        initial: 0
    );

    $taxSum = array_reduce(
        array: $aggregatedItems,
        callback: fn($carry, $item): float => $carry + $item->tax,
        initial: 0
    );

    $grossSum = $netSum + $taxSum;

    return new AggregatedOrder($aggregatedItems, $grossSum, $taxSum);
}

First, I turn all Items in the order to AggregatedItems (using the transformation defined earlier). Then I compute the netSum and taxSum of that collection of items. I used a functional approach here with array_reduce, but you could also do it in a for loop. In the end I return the new AggregatedOrder, with the AggregatedItems, grossSum and taxSum.

At this point we are done with our core domain logic.

Turning an AggregatedOrder into a receipt (raw string):

function toReceipt(AggregatedOrder $order): string
{
    $aggregatedItemToLine = function (AggregatedItem $item) {
        return sprintf(
            'You ordered %d x %s. That makes $%.2f (incl. $%.2f tax)',
            $item->original->amount,
            $item->original->name,
            $item->netTotal + $item->tax,
            $item->tax,
        );
    };

    $lines = array_map(callback: $aggregatedItemToLine, array: $order->items);

    $total = sprintf(
        'The total is $%.2f (incl. $%.2f tax)',
        $order->grossTotal,
        $order->taxTotal,
    );

    $strings = [...$lines, $total];

    return implode("\n", $strings);
}

I leave it as an exercise to the reader to figure out what this function does. But notably it seems more complicated then the previous one and it only deals with very client-specific needs that are not important at all to our understanding how Order totals and taxes are calculated. It is nice to have it encapsulated here, at the edge of our program, and not somewhere in the middle.

Turning an AggregatedOrder into a client-specific JSON string

function toJson(AggregatedOrder $order): string
{
    $itemToArray = function (AggregatedItem $item) {
        return [
            'name'     => $item->original->name,
            'amount'   => $item->original->amount,
            'netTotal' => sprintf('%.2f', $item->netTotal),
            'tax'      => sprintf('%.2f', $item->tax),
        ];
    };

    return json_encode([
        'items'      => array_map(callback: $itemToArray, array: $order->items),
        'grossTotal' => sprintf('%.2f', $order->grossTotal),
        'taxTotal'   => sprintf('%.2f', $order->taxTotal),
    ], JSON_PRETTY_PRINT);
}

Not much to say here honestly. Again, it's just an alternative way to represent an AggregatedOrder to the outside and should not influence our core logic.

Let's add the functions to the map as well, this should give you a better overview:

The core of everything is: "how to transform an Order into an AggregatedOrder". This is the hard part, with calculations and so on. But the good thing about it is, the functions in here are idempotent: They always return the same result, given the same parameters. They do not have side effects. They do not mutate outside state.

We pulled two anti corruption barries up at each side of the core. In this layer, we perform conversions from outside world information to internal models, and back. The outside world does NOT influence our internal model anymore. If the outside world changes, we can make adjustments in the anti corruption layer and the whole core does not need to change at all. The core only changes, if our internal understanding of the business changes.

Now, there is one piece missing in this story. What exactly is now a feature? It is simply one path from left to right, through this network.

The red path is our original feature: Taking the raw text order, calculating, then returning a raw text receipt.

Here is the source code for this feature:

// One path through the system.
function fromRawToReceipt(string $input, float $taxRate): string
{
    $order = rawStringToOrder($input);
    $aggregatedOrder = processOrder($order, $taxRate);
    return toReceipt($aggregatedOrder);
}

$input = getInput(); // db, file, request, ...
$taxRate = getTaxRate(); // perhaps a config value
$receipt = fromRawToReceipt($input, $taxRate);

echo $receipt;

Let's do it for the other feature, accepting JSON and returning JSON:

// Another path through the system.
function fromJsonToJson(string $input, float $taxRate): string
{
    $order = jsonStringToOrder($input);
    $aggregatedOrder = processOrder($order, $taxRate);
    return toJson($aggregatedOrder);
}

$input = getInput(); // db, file, request, ...
$taxRate = getTaxRate(); // perhaps a config value
$response = fromJsonToJson($input, $taxRate);

echo $response;

As you can see, thinking about the actual building blocks and the transformations between them, in a pure way where every function creates and returns something, without side effects, lets you compose functionality easily.

We could just as easily add a path that accepts raw text and returns the JSON response. We can easily build another function that transforms an AggregatedOrder to yet another JSON format (because maybe there is another client in the works). We can accept new inputs as well. Just make sure to keep the inputs / outputs and your internal model separated.

Here is a test for the rawToRaw path:

public function testRawToRawExample(): void
{
    $rawTextInput = <<<TEXT
        Hamburger, 6.95, 2
        Cheeseburger, 7.95, 2
        Fries, 3.25, 3
        TEXT;

    $expectedRawOutput = <<<TEXT
        You ordered 2 x Hamburger. That makes $15.01 (incl. $1.11 tax)
        You ordered 2 x Cheeseburger. That makes $17.17 (incl. $1.27 tax)
        You ordered 3 x Fries. That makes $10.53 (incl. $0.78 tax)
        The total is $42.71 (incl. $3.16 tax)
        TEXT;

    $rawResult = fromRawToReceipt($rawTextInput, taxRate: 0.08);

    $this->assertEquals($expectedRawOutput, $rawResult);
}

And a test for the jsonToJson path:

public function testJsonToJsonExample(): void
{
    $jsonTextInput = <<<JSON
    {
        "items": [
            {"name": "Hamburger","price": 6.95,"amount": 2},
            {"name": "Cheeseburger","price": 7.95,"amount": 2},
            {"name": "Fries","price": 3.25,"amount": 3}
        ]
    }
    JSON;

    $expectedJsonOutput = <<<JSON
    {
        "items": [
            {
                "name": "Hamburger",
                "amount": 2,
                "netTotal": "13.90",
                "tax": "1.11"
            },
            {
                "name": "Cheeseburger",
                "amount": 2,
                "netTotal": "15.90",
                "tax": "1.27"
            },
            {
                "name": "Fries",
                "amount": 3,
                "netTotal": "9.75",
                "tax": "0.78"
            }
        ],
        "grossTotal": "42.71",
        "taxTotal": "3.16"
    }
    JSON;

    $jsonResult = fromJsonToJson($jsonTextInput, taxRate: 0.08);

    $this->assertEquals($expectedJsonOutput, $jsonResult);
}

Summary

In this article, I showed you how thinking about your domain model can help you build flexible code. Make sure to visualize all the building blocks of your application, and the transformations between them. Visualize what is core logic, what is representation layer, and what is (unstable) imperative shell, and keep all of these parts separate.

Instead of building "one solution", create the building blocks that let you easily compose any solution.

PS: I know the use of array_map(), array_reduce() etc is a bit ugly. I would have preferred to use chainable Collections instead, however I wanted this code to be able to run standalone in any online interpreter with only PHP as a dependency.

Bonus

The article is becoming quite long, and we did not talk about the Imperative Shell at all this time. What happened to getInput(), getTaxRate(), echo? The short answer is, nothing, they still work the same and they are not relevant to our core at all.

However, I am mixing some concepts here, namely Domain Driven Design, Anti Corruption Layer and Functional Core / Imperative shell. I decided to beef up my diagram and include everything into it, to give a clear picture of how everything fits together.

Next Up

These concepts are all nice and good, but when you need side effects in your core? What if we want to emit logs to a remote system via web requests? What if a process in my "functional core" depends on a previous result, and then has to make a database query based on that result to get more information?

Consider subscribing to the newsletter if you want to be notified about new articles!