Uppercasing a country code looks like normalization:
return mb_strtoupper($input);It makes fi and FI equal. It also turns finland into FINLAND and leaves
FIN looking plausible.
The function has changed the string’s shape. It has not answered the domain question: is this a supported country identity in the representation this boundary promises?
A primitive becomes expensive when every caller must remember that answer. The reason to introduce a domain type is not that strings are unfashionable. It is that the scalar no longer carries enough of the contract.
Shape is not meaning
A retained request pipeline once converted sender and receiver country values
to uppercase before later processing. That was useful. Downstream code no
longer had to compare both fi and FI.
The conversion could not establish any of these properties:
- the value contains exactly two letters;
- it is an assigned country code;
- the application supports it;
- a three-letter code should be rejected rather than converted;
- a country name should be rejected rather than guessed; or
- two values are equal after canonicalization.
Those are separate decisions. A regular expression can establish the first. A catalogue or enum is needed for assignment and support. A conversion policy is needed if the boundary accepts more than one representation.
Calling all of that “validation” hides the useful distinction:
normalization
different accepted spellings become one representation
validation
unsupported values are rejected
conversion
one representation is deliberately translated into anotherUppercasing fi is normalization. Turning FIN into FI would be conversion.
Rejecting Finland is validation. One strtoupper() call cannot choose all
three policies honestly.
Put one canonical form inside the boundary
The retained codebase now contains a country object with a narrower contract:
final readonly class CountryCode
{
private function __construct(
public string $value,
) {}
public static function fromString(string $input): self
{
$candidate = mb_strtoupper($input);
if (strlen($candidate) !== 2 || !Catalogue::contains($candidate)) {
throw new InvalidArgumentException('Unsupported country code');
}
return new self($candidate);
}
}The real implementation also exposes country metadata and behavior. The important part here is smaller:
accepted boundary input: mixed-case, two-letter code
internal representation: upper-case, two-letter code
rejected input: unknown code, three-letter code, name, nullOnce construction succeeds, internal code does not have to repeat
mb_strtoupper(), length checks, and catalogue lookups. Equality can compare
canonical values:
$origin->value === $destination->valueThe type has removed a question from each caller. It has not made the question disappear; it has assigned ownership.
Conversion must remain an explicit policy
It is tempting to make construction helpful:
CountryCode::fromString('FIN'); // FI
CountryCode::fromString('Finland'); // FI
That broadens the contract considerably.
A three-letter code has a standardized relationship to a two-letter code. A localized country name does not have the same stable identity. Names can be ambiguous, translated, misspelled, or changed. Silently guessing from all three forms makes it difficult to know what the caller actually supplied.
Use a separate converter when a particular boundary genuinely accepts another representation:
$country = Alpha3CountryCode::fromString($input)->toAlpha2();Now the conversion is searchable, testable, and removable without changing ordinary two-letter construction. A CSV importer may accept three-letter codes while an API rejects them. Those boundaries do not need one maximally permissive constructor.
This separation also preserves raw evidence. If an integration sends a country name where its contract requires a code, converting it silently may hide a provider regression. Rejecting it gives the boundary a chance to report the actual mismatch.
Behavior belongs with the identity it depends on
Country rules are not limited to formatting the code. The retained country object can answer questions such as whether an address format includes a region or postal code and whether the country belongs to a configured economic area.
That does not mean every fact about a country belongs in one object.
Display names depend on locale. Address templates and subdivisions come from catalogues which can be updated independently. Tax and shipping eligibility often depend on date, product, agreement, and customer—not country alone.
A useful boundary is:
CountryCode
owns canonical identity and equality
CountryCatalogue
supplies current descriptive metadata
EligibilityPolicy
combines country identity with business contextPutting a localized name into equality would make two references to the same country differ with locale. Putting a commercial rule into the code type would make a stable identifier change for reasons outside its identity.
Promoting a primitive should make ownership narrower, not create a new junk drawer.
The type should cross the boundary that needs the guarantee
Wrapping a string and immediately unwrapping it achieves little:
$country = CountryCode::fromString($request->country);
$service->book($country->value);If the service then passes raw strings through five more application methods, each method can reintroduce invalid or non-canonical values. The guarantee ended at the first line.
Prefer the type across the owned application boundary:
$command = new BookShipment(
origin: CountryCode::fromString($request->origin),
destination: CountryCode::fromString($request->destination),
);Keep it typed through decisions and comparisons. Convert to the provider’s wire representation only inside the adapter:
'country' => $command->destination->value,The outside world still speaks strings. A database column and JSON payload do not become objects. The useful question is where a string stops being untrusted representation and starts being a known domain value.
Static analysis can protect selected crossings
The retained application later added a static-analysis rule for configured integration normalizers. Those methods must invoke an approved country enum or value object rather than only manipulate a raw string.
The rule is intentionally narrow. It does not guess from property names such
as country, and it does not claim to prove the value on every return path.
Configured methods are checked; unconfigured methods are not.
That limitation is healthier than a broad heuristic. A field named country
might contain a display name. A field named region might carry the actual
country code for one provider. Static analysis should enforce a declared
boundary, not pretend naming conventions are a type system.
The control is evidence of recurring pressure, not proof that the migration is complete. The retained codebase still contains scalar country values at framework and integration edges.
Promote a primitive when the decision repeats
Not every string deserves a class.
A private formatter receiving one already validated code may be clearer with a string. A transport DTO may need to mirror a provider’s wire schema exactly. A temporary import script may gain nothing from carrying a domain type beyond one local check.
Promotion earns its cost when several of these are true:
- invalid values can reach meaningful work;
- multiple accepted spellings need one canonical form;
- comparisons repeat normalization;
- conversion between representations needs an explicit policy;
- behavior depends on the value’s meaning;
- framework or integration boundaries repeatedly lose the guarantee; or
- a later change must find every place where the concept enters the system.
The cost is real. Serialization, database casts, framework validation, and third-party clients all need adapters. A type with one accessor and no invariant may add ceremony without removing a decision.
Start with the repeated question, not the pattern name:
What must every caller remember about this value?If the answer is only “it is text,” keep the string. If the answer includes valid representations, canonicalization, comparison, and conversion policy, the primitive is already hiding a domain decision.
Prove normalization and rejection separately
The public reproduction compares an uppercase-only function with a small canonical country type. It executes mixed-case input, valid canonical input, an unknown two-letter value, a three-letter value, and a country name.
The fixture proves only the synthetic contract. It does not run the private request pipeline, country catalogue, or static-analysis rule. Retained artifacts establish those mechanisms, but they do not retain incident counts, a complete migration inventory, or measured defect reduction.
Tests for a production type should keep the decisions separate:
mixed-case accepted representation -> canonical code
canonical representation -> unchanged code
unknown two-letter value -> rejected
three-letter representation -> rejected or explicitly converted
country name -> rejected or explicitly resolved
two successful values -> equal by canonical identityDo not let one happy-path assertion stand in for all of them. The value of the type is the set of questions its callers no longer have to answer.