A parcel weighs two kilograms. The tariff has a price for two kilograms. Returning that price sounds reasonable. It can also be wrong before the lookup finishes: the tariff may charge for volume, the account may have a negotiated transport rate, and handling may apply to each parcel rather than once to the shipment.
I built a private logistics rating engine around those interactions. It stayed private rather than becoming a commercial product. What made it interesting was that most of the difficulty came from pricing itself. I could work on the model without first accommodating years of unrelated business exceptions. There was still plenty to get wrong.
The PHP implementation eventually had a configurable price book, a separate runtime representation, a staged evaluator, and a durable record of each completed decision. Understanding those pieces matters before discussing the formula redesign or the later Go port. Changing languages would not make their responsibilities disappear.
The rates and identifiers below are fictional. The schema and code excerpts follow the implementation, with ownership names shortened and unrelated fields, namespace imports, and tracing plumbing omitted where stated. They describe the table-backed PHP design reached during development, not a claim that the first version already looked like this.
A small quote creates several different decisions
Take one parcel weighing 2 kg and measuring 40 × 25 × 24 cm. Its configured
weight policy compares physical weight with volumetric weight, selects the
greater value, and rounds upward to the next half kilogram. With a divisor
of 5,000 cm³/kg:
volume = 40 × 25 × 24 = 24,000 cm³
volumetric weight = 24,000 / 5,000 = 4.8 kg
selected weight = max(2, 4.8) = 4.8 kg
chargeable weight = ceil(4.8 / 0.5) × 0.5 = 5.0 kgThose numbers have different meanings. The scale reading remains two
kilograms. The chargeable weight becomes five. A rule about a physical
handling limit should not quietly read the price-selection metric because
both fields happen to contain a number followed by kg.
Order matters too. Selecting a bracket from 4.8 and rounding afterward can
choose a different row from rounding first and selecting from 5.0.
With two parcels, rounding each before summing can differ from rounding the
sum. Two values of 4.6 kg become 5 + 5 = 10 kg when rounded separately;
rounding their 9.2 kg sum once produces 9.5 kg. A general-purpose
expression evaluator can calculate either answer. The pricing policy must
say which answer belongs to this tariff.
For the one-parcel quote, assume the destination resolves to zone B and
the selected calculation instant falls in a winter season. Four configured
charges are relevant:
- Generic transport costs EUR 30 for the parcel service, zone B, winter, and shipment chargeable weight from 5 through 6 kg.
- A negotiated transport schedule costs EUR 24 under the same conditions for an eligible account group. Its override mode replaces generic transport.
- Handling costs EUR 3 for each package with chargeable weight of at least 5 kg.
- A seasonal charge adds EUR 4 to the shipment.
The ordinary account gets 30 + 3 + 4 = 37. The negotiated account gets
24 + 3 + 4 = 31. Summing every matching row would produce 61 for the
negotiated account. Selecting the first match would leave the outcome
dependent on an ordering rule we have not specified.
Already there are separate questions: derive the metric, identify the
configuration, find eligible rows, decide which components replace or
accumulate, and round the resulting money. Putting all of that behind
findPrice(weight, postcode) would conceal most of the application.
The price book is a graph with a ruleset at its root
A request does not carry the complete tariff. It refers to an operation inside an ownership scope, and the engine resolves the configuration that applies at its decision instant. The PHP model separated that authoring configuration from both the shipment and the result.
The central relationships looked like this. The names are shortened here; the parallel references are representative, not the complete profile:
ruleset
└── pricing profile
├── geography catalog ─── geography rows
├── weight policy
├── seasonal calendar ─── season rows
├── tariff grid ───────── tariff rows
├── contract schedule ─── contract rows
└── precedence policy ─── precedence rules
request shipment + selected configuration
└── evaluation
└── decision + returned options + scoped price componentsA geography catalog classifies an address. A weight policy supplies the
basis used by later calculations. A tariff row materializes a monetary
component. A precedence policy governs relationships between components.
These are not interchangeable objects merely because they all influence
a price. Giving every one the name Rule would postpone the distinction
until the evaluator had to rediscover it.
The profile has at most one row for a particular tenant/ruleset pair. Its nullable references bind that ruleset to specific artifact records. Changing a tariff does not mean replacing every geographic classification or currency setting. Conversely, selecting an unrelated latest tariff because it has the same friendly name would break the profile’s meaning.
Here is the shape of that binding in SQL. These are selected columns and constraints from the profile migration, with names shortened. Delete actions, the tenant-registry reference, other artifact references, and audit fields are omitted. This is a relationship excerpt, not a drop-in migration:
CREATE TABLE ruleset_pricing_profiles (
id char(26) PRIMARY KEY,
tenant_id char(26) NOT NULL,
ruleset_id char(26) NOT NULL,
tariff_grid_id char(26),
contract_schedule_id char(26),
UNIQUE (tenant_id, ruleset_id),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, ruleset_id)
REFERENCES rulesets (tenant_id, id),
FOREIGN KEY (tenant_id, tariff_grid_id)
REFERENCES tariff_grids (tenant_id, id),
FOREIGN KEY (tenant_id, contract_schedule_id)
REFERENCES contract_schedules (tenant_id, id)
);The paired ownership keys are doing real work. An existing tariff identifier
is not sufficient: it must belong to the same tenant as the profile.
That relationship is checked for an import as well as an ordinary application
write. The generated PostgreSQL schema used char(26) identifiers, with
version identity stored separately on the artifact root.
The artifact roots also carried a code, version, lifecycle state, concurrency version, effective dates, and publication time. A code/version uniqueness constraint establishes identity within an ownership scope. It does not prove that the artifact will never be edited. Treating “versioned” as a synonym for “immutable” would be a fairly expensive assumption here. The write commands, publication checks, and competing writers still determine that contract.
There is a second kind of configuration around the profile. The runtime
also needs such things as option relationships, override policy, currency
metadata, and runtime policy. The context resolver selects their active
identities and obtains a prepared bundle. Some can change active version
without changing the ruleset. Keeping only ruleset_id in every cache key
would therefore be insufficient.
That is why the configuration map needs more than a single rectangle labelled “rules.” It contains independently governed data, explicit bindings, and time-dependent selections. The evaluation needs a particular combination, not whatever an assortment of queries happens to call current.
A tariff row has fixed structure and variable dimensions
Moving pricing into tables did not mean creating one column for every conceivable logistics measurement. The grid row exposed facts that the system needed to govern consistently, while retaining structured conditions for dimensions that vary between tariffs.
A reduced tariff-row declaration shows that split:
CREATE TABLE tariff_rows (
id char(26) PRIMARY KEY,
tenant_id char(26) NOT NULL,
tariff_grid_id char(26) NOT NULL,
sequence integer NOT NULL,
row_code varchar(255) NOT NULL,
kind varchar(255) NOT NULL DEFAULT 'base_method',
scope varchar(255) NOT NULL DEFAULT 'shipment',
service_match_json json,
required_geography_codes_json json,
required_season_codes_json json,
zone_codes_json json,
metric_axes_json json,
row_priority integer NOT NULL DEFAULT 0,
currency_code varchar(255) NOT NULL,
amount decimal(20, 6) NOT NULL,
group_code varchar(255),
precedence integer NOT NULL DEFAULT 0,
UNIQUE (tenant_id, tariff_grid_id, row_code),
UNIQUE (tenant_id, tariff_grid_id, sequence),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, tariff_grid_id)
REFERENCES tariff_grids (tenant_id, id)
);Other matching fields, delete actions, and timestamps are omitted from that excerpt. The important boundary is visible without them: ownership, parent identity, sequence, monetary amount, currency, scope, and composition fields have declared roles. Conditions can still vary without becoming an arbitrary program that defines what a tariff row is.
The metric field contains a list of axes. For the generic transport row in our quote, its relevant configuration could be:
{
"row_code": "zone-b-winter-transport",
"scope": "shipment",
"kind": "base_method",
"service_match": {
"service_ids": ["parcel"]
},
"required_season_codes": ["WINTER"],
"zone_codes": ["B"],
"metric_axes": [
{
"metric_type": "total_chargeable_weight",
"metric_unit": "kg",
"minimum_value": 5,
"maximum_value": 6
}
],
"amount": "30.000000",
"currency_code": "EUR",
"group_code": "transport"
}This is a runtime-row shape, not an HTTP create request. The storage fields
with _json suffixes become arrays in the projected representation.
A more demanding tariff can have several axes on the same row: weight
within one range and a dimension within another, for example. The matcher
must evaluate all configured axes; finding one satisfactory measurement is
not sufficient.
The model made the conversion boundary explicit in its casts. The relevant PHP method was:
protected function casts(): array
{
return [
'service_match_json' => 'array',
'required_geography_codes_json' => 'array',
'required_lane_codes_json' => 'array',
'required_goods_classification_codes_json' => 'array',
'required_season_codes_json' => 'array',
'required_offer_codes_json' => 'array',
'zone_codes_json' => 'array',
'metric_axes_json' => 'array',
'row_priority' => 'integer',
'amount' => 'decimal:6',
];
}This was a Laravel-native domain model backed by Eloquent. Hydration included the framework’s array casts and decimal formatting, rather than just copying database fields into an object. A later implementation had to account for the values exposed through that model boundary as well as the column types.
The amount’s six-decimal storage and cast do not mean every numerical operation in the application uses decimal arithmetic. The metric matcher uses floating-point measurement values. Money and physical measurements have different paths and rounding rules; calling both “precision” and assuming the schema solved them would hide another translation problem.
A database can reject a cross-tenant parent or duplicate row code. It cannot infer that the JSON axes describe the intended tariff, or that two valid rows should not both produce competing base charges. Those are questions for the artifact validators, publication checks, and evaluator. The table design made the questions more inspectable. It did not eliminate them.
The incoming request becomes an admitted operation
The HTTP controller was deliberately uninteresting. It validated the document, mapped it into application input, invoked the evaluation use case, and turned the result into the transport document or canonical error. Pricing did not happen inside the controller.
Below that adapter, an application input contained a business payload and authorization context. A reduced internal payload looks like this:
{
"context": {
"request": {
"request_id": "request-A",
"idempotency_key": "quote-A",
"operation_code": "rate_quote",
"execution_mode": "live",
"timezone": "UTC"
},
"scope": {
"tenant_id": "tenant-A"
},
"customer": {
"customer_id": "account-A"
}
},
"shipment": {
"packages": [
{
"id": "package-A",
"type": "parcel",
"weight": {"value": "2", "unit": "kg"},
"dimensions": {
"length": {"value": "40", "unit": "cm"},
"width": {"value": "25", "unit": "cm"},
"height": {"value": "24", "unit": "cm"}
}
}
]
}
}Ownership and identifiers are renamed, and the destination, goods, options, and request-contract details are left out to keep this view readable. The complete operation needs its admitted contract and decision instant as well. The point is that scope, logical operation identity, and shipment facts remain different parts of the input.
The request mapper combined validated transport fields with the business attributes. It supplied the canonical request and scope fields rather than letting an arbitrary nested business object redefine them. Authorization was carried separately. Having a syntactically valid tenant identifier did not make a caller entitled to use it; admission had to establish the appropriate context.
Admission came before the engine’s calculation. The application checked the execution mode and required an idempotency key for the live path. It then normalized and admitted the operation, resolved an existing outcome if one was available, and attempted a processing claim before new execution.
A correlation ID and an idempotency key have different purposes. The former lets us connect observations about a request. The latter participates in deciding whether this logical operation should be executed again. Changing only the correlation ID in a benchmark can therefore produce a fresh-looking log trail around a replay. That distinction later became important when investigating the benchmark harness.
There are several exits before pricing starts: invalid input, missing required identity, failed admission, an existing outcome, or an unsuccessful claim. A timing around the HTTP endpoint has to account for which of those paths actually ran. HTTP success alone is not a useful count of newly calculated quotes.
Derived values run before candidate pricing
The orchestration separates computed-property evaluation from option evaluation. These were two real calls in the execution runner, shown here without the surrounding timing and failure-result construction:
$computed = ($this->computedProperties)(
new EvaluateComputedPropertiesInputData(
divisionId: (string) $admission->divisionId,
operationCode: (string) $admission->operationCode,
decisionInstantAt: $admission->decisionInstantAt,
canonicalPayload: $admission->canonicalPayload ?? [],
),
$input->traceStagesAllowed ? $recordBenchmarkStage : null,
);
// A failed computed result returns through the failure finalizer.
// Only a successful result reaches this call.
$evaluated = ($this->runtimeOptions)(
new EvaluateRuntimeOptionsInputData(
divisionId: (string) $admission->divisionId,
operationCode: (string) $admission->operationCode,
requestId: $requestId,
requestContractVersion: (string) $admission->requestContractVersion,
decisionInstantAt: $admission->decisionInstantAt,
canonicalPayload: $admission->canonicalPayload ?? [],
computedPropertyValues: $computed->computedPropertyValues,
traceStagesAllowed: $input->traceStagesAllowed,
),
);The source calls the ownership scope a division; it is the tenant boundary shortened in the earlier diagrams and data. The quoted field names are retained here so the dependency between the actual typed inputs is visible.
The computed-property pipeline gives configured numeric derivations their own execution path. Those values become inputs to the option engine. The table redesign did not remove formulas; it stopped requiring every ordinary pricing fact to be authored as one. A formula needed by a later condition must run before that condition consumes its result.
If computed evaluation fails, the runner does not substitute an empty map and carry on. It constructs a failure, including missing-property information where available, and routes it through the failed-claim finalizer. That failure path is part of the operation, just as much as the successful total. A Go translation that produced a nicer default could silently change which requests succeed.
The option engine then resolves customer scope, the active ruleset, and the runtime context bundle for the chosen instant. It checks whether the configuration requires explicit transport legs, resolves effective candidate services, and runs candidate execution. Candidate execution itself separates policy filtering, price assembly, and post-processing.
That is a fairly specific set of responsibilities:
admitted payload
|
+-- computed-property evaluation
|
+-- runtime context and active ruleset
|
+-- candidate services
|
+-- policy filtering
|
+-- per-candidate price assembly
|
+-- option post-processingThe branches in this drawing describe stages, not independent work scheduled in parallel. In particular, computed results are passed into later stages, and candidate pricing consumes the resolved context. Moving these calls around because they look like separate services would change their inputs.
The pricing context also separates request-wide information from information specific to a candidate. Two services can use different policies or supported options for the same parcel. Reusing a mutable context between them without preserving those distinctions would let one candidate influence another.
Matching has a request boundary and a scope boundary
The tariff matcher does not answer every eligibility question in one method. Its request-level method checks whether the row applies to the candidate and the resolved route/geographic/seasonal conditions:
public function rowMatchesRequest(
array $row,
array $candidate,
array $requestedOptionCodes,
array $resolvedGeographyCodes,
array $resolvedLaneCodes,
array $resolvedSeasonCodes,
array $resolvedZoneCodes,
): bool {
if (!$this->pricingProfileRowMatcher->rowMatchesCandidate(
$row,
$candidate,
$requestedOptionCodes,
)) {
return false;
}
return $this->criteria->pricingTariffGridRowMatchesCriteria(
$row,
$resolvedGeographyCodes,
$resolvedLaneCodes,
$resolvedSeasonCodes,
$resolvedZoneCodes,
);
}The scope-level method then evaluates the metric axes and requirements against the context for the particular shipment, package, item, or other supported pricing scope:
public function rowMatchesScope(
array $row,
array $resolvedOfferCodes,
?array $pricingGoodsClassificationTable,
array $scopeContext,
): bool {
if (!$this->criteria->pricingTariffGridRowMatchesMetricAxes(
$row,
$scopeContext,
)) {
return false;
}
return $this->requirementResolver->matches(
$row,
$resolvedOfferCodes,
$pricingGoodsClassificationTable,
$scopeContext,
);
}This split stops a request-level match from being mistaken for permission to create every scoped charge. A service can be eligible for a shipment while only one of its parcels meets a heavy-package condition. Similarly, an eligible tariff row can still require an offer or a goods classification that the current scope does not satisfy.
The axis matcher is small enough that its important loop can be shown directly:
$metricAxes = is_array($row['metric_axes'] ?? null)
? $row['metric_axes']
: [];
foreach ($metricAxes as $axis) {
if (!is_array($axis)) {
return false;
}
$resolvedAxis = $this->axisMap($axis);
$metricType = $this->values->stringValue($resolvedAxis, 'metric_type');
$metricUnit = $this->values->stringValue($resolvedAxis, 'metric_unit');
if ($metricType === null || $metricType === '') {
return false;
}
$metricValue = $this->metrics->pricingMetricValue(
$metricType,
$metricUnit,
$context,
);
if ($metricValue === null) {
return false;
}
$minimumValue = is_numeric($resolvedAxis['minimum_value'] ?? null)
? (float) $resolvedAxis['minimum_value']
: null;
$maximumValue = is_numeric($resolvedAxis['maximum_value'] ?? null)
? (float) $resolvedAxis['maximum_value']
: null;
if ($minimumValue !== null && $metricValue < $minimumValue) {
return false;
}
if ($maximumValue !== null && $metricValue > $maximumValue) {
return false;
}
}
return true;The consequences follow from the comparisons. Bounds are inclusive. A missing
or unresolvable metric fails the row. A configured list of axes acts as a
conjunction because every axis must survive. An empty list reaches the final
true without imposing a metric restriction.
There is also a less obvious source behavior: a non-array metric_axes
value becomes an empty list at this boundary. That does not establish that
every authoring/API path accepts malformed axes. It says what this matcher
does with the value it receives. The publication validators and input
contracts are different boundaries, and a faithful port has to understand
all of them rather than infer one from another.
The same loop treats a non-numeric bound as absent. That is another reason to test the write/publication boundary separately from this reader: a range matcher that tolerates a missing lower bound is not a validator for arbitrary configuration strings. These details matter when translating the code, even if ordinary editor-created rows never exercise them.
The small axisMap helper in that method returns its array argument; it
does not add a hidden validation or normalization pass. The conversions shown
in the loop and the metric resolver are the ones responsible for those
results.
The metric resolver chooses the correct fact before converting it into the
axis’s unit. weight reads a package’s physical weight;
chargeable_weight reads its chargeable weight;
total_chargeable_weight reads the shipment aggregate. Package count and
maximum package weight are separate metric names. Canonical weight is in
kilograms, lengths in centimeters, and volume in cubic meters in this
implementation; a row can request an appropriate supported unit.
For the shipment transport row, the example’s total chargeable weight is
5 kg, so the 5..6 kg axis passes. A package handling row can separately
ask for that package’s chargeable_weight >= 5 kg. The same numeral appears
in both contexts only because this shipment has one parcel. Adding another
parcel makes the distinction unavoidable.
This is why the underlying data matters. The engine cannot reproduce those
semantics from a single unqualified field named weight, and a query that
returns a tariff amount has not done the work of constructing the correct
scope.
The matched rows become separate components
The generic transport, negotiated replacement, handling, and seasonal charge do not all mean “add this row’s amount.” They first become components with a kind, scope, monetary value, and relationships used by the composition rules.
Under the example’s negotiated configuration, replacement changes the transport component while handling and the seasonal charge survive. A global maximum-priority comparison across unrelated kinds would be wrong: a high-priority handling row should not remove the base transport simply because both have a priority field.
The database fields reflect that there are different concerns. Row priority and sequence participate in selecting and ordering rows. Kind, group, precedence, and contract override semantics participate in what the components mean together. They should not be casually collapsed into one ordering number during a performance refactor.
For package A, a readable explanatory component is:
{
"kind": "handling",
"code": "heavy-package",
"scope": "package",
"package_id": "package-A",
"amount": {"value": "3.00", "currency": "EUR"},
"basis": {
"table_version": "handling-v3",
"row": "weight-at-least-5kg",
"chargeable_weight_kg": "5.0"
}
}The serialized decision has a richer graph; this shortened shape exposes the facts needed to explain the charge. It is not the private response contract. The independent component, its package identity, the contributing row, and the resolved measurement all matter when explaining or comparing results.
Add package B with the same measurements. Handling now materializes two
package charges. Transport selection receives the configured shipment
aggregate, which is 10 kg under this example’s per-package rounding.
The old five-to-six-kilogram transport row no longer applies. Keeping its
EUR 24 because the request still contains the word “parcel” would be a
different calculation, however attractive its total looked.
The EUR 6 handling subtotal alone cannot tell us whether two packages each incurred one charge, one package incurred two, or an adjustment happened to have the same amount. Scoped component instances retain that distinction. They also let later composition steps operate on identifiable contributions rather than reverse-engineer meaning from a scalar total.
Currency metadata belongs to this process too. The model’s decimal:6
amount is not permission to sum currencies or ignore terminal rounding.
The runtime carries a selected currency-metadata version into candidate
assembly and option post-processing. A missing required currency conversion
can fail evaluation; substituting a current rate would change the selected
calculation.
There are counterintuitive outcomes in the existing contract as well. One seasonal no-match scenario expects an option with zero components and a zero total. I would not silently turn every no-match outcome into an error while claiming a compatible rewrite. Whether that product behavior should change is a separate decision from preserving the implementation.
One decision instant is not a database snapshot
A quote depends on account scope, service configuration, geography, seasons, tariffs, formulas, and runtime policy. The selected instant is passed from admission into computed evaluation and option evaluation. The context resolver uses it for customer scope, active ruleset, and runtime bundle resolution.
That stops one request from consulting a new wall-clock time halfway through each candidate. A quote evaluated around a seasonal boundary should not accidentally use one window for the first candidate and another for the last because the loop took time.
It does not, by itself, freeze the database. The application does not run the entire calculation inside the successful-finalization transaction. And naming a profile a snapshot does not force every child writer to obey an immutability protocol. Effective-time selection, configured references, content identity, and transaction isolation are related but distinct mechanisms.
The runtime representations make that distinction important. The request normally consumes prepared ruleset/profile data rather than issuing one tariff-row lookup per component. The cache and persisted projection reduce repeated graph loading and preparation, while active identity resolution still has work to do. If source content changes under an unchanged identity, a prepared reader needs a way to detect or invalidate it.
A price-book map without the read path would hide that problem. A read-path diagram without the stored relationships would hide why a particular cached combination has meaning. Both are needed to understand the later performance work.
A successful response has a smaller durable boundary than the full snapshot
After the engine has computed options, the use case invokes its successful finalizer through a transaction-boundary contract. The database adapter for that contract delegates to Laravel’s transaction mechanism:
$transaction = static fn (Connection $_connection): mixed => $callback();
return $this->database->transaction(
$transaction,
max(1, $attempts),
);The computation has already run by this point. Inside the callback, the finalizer constructs an envelope and saves the decision root. These are the source calls, without the timers around them:
$decisionId = $this->ulids->generate();
$decisionEnvelope = DecisionSnapshotEnvelopeData::fromEvaluation(
decisionId: $decisionId,
requestId: $requestId,
admission: $admission,
computedProperties: $computed,
runtimeOptions: $evaluated,
);
$decision = $this->decisionGraphFactory->buildRoot($decisionEnvelope);
$this->decisions->saveDecisionRoot($decision);
$this->completedOutcomeRecorder->record(
admission: $admission,
evaluated: $evaluated,
claim: $claim,
decisionId: $decisionId,
idempotencyKey: $idempotencyKey,
responseDiagnostics: $responseDiagnostics,
recordBenchmarkStage: $recordBenchmarkStage,
);The completed-outcome recorder builds the response payload, serializes it, calculates its replay hash, and marks the idempotency record completed with the decision identity and outcome reference. It does not need to reconstruct the entire later snapshot graph to store the response.
The relevant persisted relationships are:
idempotency record
unique: tenant + operation + idempotency key
unique: tenant + operation + request ID
request hash, status, response hash
decision identity ──────────────────────────────┐
outcome reference: │
response format + compressed response │
▼
decision
│
full snapshot work ────┼── shipping options
│ └── price components
├── input/package/item data
└── execution evidenceThe schema links the idempotency record to the tenant-scoped decision identity with a deferrable foreign key. The two uniqueness constraints on request and idempotency identity are separate; one is not a substitute for the other.
The outcome codec’s stored format carries the response as compressed JSON:
return [
'decision_id' => $decisionId,
'response_payload_format' => self::COMPRESSED_RESPONSE_PAYLOAD_FORMAT,
'response_payload_gzip_base64' => $this->compressResponsePayload(
$responsePayload,
$responsePayloadJson,
),
];The format constant is gzip_base64_json_v1. Replay can therefore use the
completed stored response rather than waiting for every child row to exist.
That is a concrete storage decision, not just a promise that an operation
is idempotent. The source also retained a decoder path for an earlier
array-shaped response payload.
The finalizer records a completion event in the outbox and dispatches the heavier snapshot work. The durable response, outbox entry, and fuller audit graph are distinct objects with different consumers.
The queue adapter makes the handoff timing visible:
public function dispatch(DecisionSnapshotEnvelopeData $envelope): void
{
PersistDecisionSnapshotsJob::dispatch($envelope)->afterCommit();
}This avoids handing the job an envelope for an enclosing transaction that later rolls back. It does not make queue publication and database commit one indivisible operation. The outbox record and queued snapshot are also different responsibilities; the presence of the former is not proof that every piece of background work is an outbox consumer.
The snapshot job builds the fuller decision graph and invokes the snapshot recording use case. A failed result causes an exception rather than a false successful job. The job allows five attempts and reports permanent failure. Consequently, a successful synchronous quote and a completed heavy snapshot are different observable states.
That split was useful during performance work because the caller did not need to wait for every diagnostic and snapshot child to be written before receiving the price. But it creates a verification obligation. A benchmark for completed quotes must check the synchronous decision/idempotency boundary. A benchmark for fully persisted snapshots must wait for that later boundary. Counting one as the other produces a flattering number with the wrong meaning.
It also affects replay. A processing claim, a completed outcome, the returned decision identity, and a background snapshot cannot be treated as one status flag. The benchmark and migration work had to preserve those links and the failure order around them. Fast calculation is only one part of the operation.
The architecture makes the optimization questions concrete
This was more machinery than I would use for one fixed tariff. If a service has a small stable rate list, unambiguous units, no negotiated composition, and a few predictable extras, ordinary code and direct data access are easier to own. There is no prize for manufacturing an authoring platform before the problem needs one.
Here, the dimensions recurred across price books and needed to vary independently. Copying a whole calculation for each combination would spread the same behavior across many implementations. Putting every interaction inside one universal formula would instead move the complexity into the configured program. The actual design moved between those pressures.
The resulting architecture exposes different kinds of cost. Loading a versioned artifact graph is preparation. Selecting active identities is resolution. Moving and decoding a prepared payload is delivery. Matching axes and constructing components is evaluation. Saving the durable receipt and completing later snapshots are persistence at different boundaries.
Those boundaries became separate optimization targets. When traces pointed to restoration of a prepared profile, changing arithmetic would leave that work in place. Deferring snapshot children changed the synchronous persistence budget while retaining a completed response to verify. The later PHP/Go comparison needed the same operation on both sides, including those parts around the calculation.
The first pricing representation did not expose all of these boundaries clearly. It expressed applicability and amounts through configurable components and expressions. Replacing that authoring path with explicit table families gave the engine more useful facts to govern and prepare. The difficult part was preserving the calculation while changing where its meaning lived.