Replace Primitive Obsession with Domain Types
A customer ID, an email address, and a currency code can all be represented as strings. That doesn’t make them interchangeable. When a codebase treats every meaningful value as a generic string, integer, or boolean, callers have to remember rules that the type itself doesn’t express.
This problem is often called primitive obsession: using general-purpose primitive values where the domain has a more specific concept. The practical fix isn’t to wrap every string in a class. It’s to introduce a domain type when doing so gives the program a useful place to enforce meaning and rules.
This article shows how to recognize that point, refactor toward a domain type without hiding important behavior, and avoid creating wrappers that add ceremony without reducing mistakes.
Start with the knowledge a primitive cannot carry
Consider a function that sends a payment:
send_payment(account_id, amount, currency)Suppose its parameters have these primitive types:
account_id: string
amount: integer
currency: stringThe types tell us little about the valid values. Can amount be negative? Is it measured in major units such as dollars or minor units such as cents? Is currency any three-character string? Can an empty account ID reach this function?
Those questions still have answers, but the answers live somewhere else: comments, validation branches, tests, naming conventions, or developer memory. The farther that knowledge is from the value, the easier it is for different parts of the program to apply different rules.
A domain type gives a domain concept its own representation and operations. Instead of passing an arbitrary integer, code might pass a Money value that combines an amount with its currency and rejects invalid construction according to the application’s rules.
The useful mental model is:
A primitive carries data. A domain type can carry data plus the rules required for that data to mean something in this program.
That doesn’t mean the type must contain every business rule involving the value. It should own rules intrinsic to the value itself.
The smallest useful refactoring
Suppose an application accepts a percentage discount from 0 through 100. Primitive-based code might validate it at each entry point:
function apply_discount(total, percentage):
if percentage < 0 or percentage > 100:
error("invalid percentage")
return total - (total * percentage / 100)Elsewhere, another function needs the same check:
function save_discount(percentage):
if percentage < 0 or percentage > 100:
error("invalid percentage")
store(percentage)The duplication isn’t only repeated syntax. It represents duplicated knowledge: both functions know what makes a percentage valid.
Introduce a type that establishes the invariant when the value is created:
type Percentage:
value
create(value):
if value < 0 or value > 100:
error("percentage must be between 0 and 100")
return Percentage(value)Callers now accept a Percentage rather than an unrestricted number:
function apply_discount(total, percentage: Percentage):
return total - (total * percentage.value / 100)The important change is not the wrapper. It is the construction boundary. Once a Percentage has been created successfully, code that receives it can rely on the range invariant instead of checking it again.
That guarantee holds only if callers cannot bypass construction and mutate the stored value into an invalid state. Languages enforce that constraint differently. Some support private constructors, immutable value types, or modules with hidden representations. In languages without those mechanisms, the same design can still help, but the guarantee depends more heavily on convention.
Put intrinsic rules with the value
A useful domain type owns rules that remain true wherever the value is used.
For an EmailAddress, that might include the application’s accepted normalization and validation policy. For a Money value, it might include preventing arithmetic between incompatible currencies unless an explicit conversion occurs. For a DateRange, it might mean guaranteeing that the end is not before the start.
These are different from contextual business rules.
Suppose a transfer amount must be positive. That can be intrinsic to a TransferAmount type if negative transfers have no meaning anywhere in the application. But a rule such as “transfers above 10,000 require approval” depends on workflow and policy. Putting that approval rule inside the amount type would make a simple value responsible for a process it doesn’t own.
A practical test is to ask:
Would this rule still describe the value if I used it in another part of the same domain?
If yes, the rule may belong with the type. If the answer depends on who is using the value, what operation is happening, or the current system state, keep the rule in the relevant service, entity, policy, or use case.
Domain types prevent accidental interchange
Validation is only one benefit. Distinct types can also stop values with identical primitive representations from being mixed up.
Consider this interface:
move_item(source_id: string, destination_id: string, item_id: string)A call can be syntactically valid while putting arguments in the wrong positions:
move_item(item_id, destination_id, source_id)If the domain distinguishes these identifiers, distinct types can make the mismatch visible:
move_item(
source_id: WarehouseId,
destination_id: WarehouseId,
item_id: ItemId
)This doesn’t prevent swapping the two warehouse IDs because they intentionally share the same type. It does prevent an ItemId from being used where a WarehouseId is expected in a type system that checks those distinctions.
That detail matters. Domain types don’t magically eliminate all argument mistakes; they eliminate the mistakes represented by the distinctions you actually model.
Make invalid states harder to construct
The strongest domain types don’t merely validate after the fact. They shape the API so invalid combinations are difficult or impossible to represent.
Imagine a delivery address where either a street address or a pickup-point identifier is required. A loose structure might allow every combination:
DeliveryAddress {
street: optional string
pickup_point_id: optional string
}Now the program can represent neither field, both fields, or exactly one. If only the last case is valid, every consumer must remember that invariant.
A more precise model separates the alternatives:
DeliveryDestination =
StreetAddress(...)
or PickupPoint(id)The exact syntax depends on the language. The design idea does not: represent the valid alternatives directly instead of representing a larger set of states and repeatedly rejecting the invalid ones.
This is related to primitive obsession because a pile of loosely related primitives often hides a richer domain concept. Sometimes the right refactoring is a small value object. Sometimes it is a tagged union, enum with associated data, or another type that represents mutually exclusive cases.
Decide where conversion should happen
Systems still receive primitives at their boundaries. HTTP requests contain text and numbers. Configuration files contain serialized values. Database drivers return storage representations. A domain model cannot prevent malformed external input from arriving.
The useful move is to convert early, at a boundary where failure can be handled explicitly:
request string
|
v
parse and validate
|
v
EmailAddress
|
v
domain logicIf parsing fails, return the boundary’s appropriate error: a validation response, rejected message, configuration error, or another explicit failure. If it succeeds, internal code receives a value with known meaning.
The reverse conversion happens when data leaves the domain boundary. A CustomerId may become text for JSON or a database column. Keeping serialization concerns at the edge prevents transport formats from becoming the domain type’s main reason for existing.
There are exceptions. Persistence frameworks or serializers sometimes require annotations or adapters on domain types. That can be a reasonable trade-off when the integration remains small and doesn’t force domain behavior to depend on infrastructure details.
Equality and identity need deliberate semantics
Many domain types are value objects: two instances are considered equivalent when their meaningful contents are equivalent. Two Percentage(20) values represent the same percentage even if they were constructed separately.
That differs from an entity, whose identity matters independently of its current attributes. Two customers with the same name are still different customers.
When introducing a domain type, decide which semantics callers need. A type such as Money(500, "USD") normally benefits from value-based equality. An identifier type such as CustomerId also commonly compares by its contained identifier value, even though it identifies an entity elsewhere.
Be careful with normalized values. If an EmailAddress type changes case or whitespace during construction, equality follows whatever normalization policy the application has chosen. Don’t add normalization casually just to make equality convenient; some forms of data have standards or business rules that make normalization more subtle than it first appears.
Don’t wrap primitives without gaining a guarantee
This refactoring becomes noisy when every primitive receives a one-field wrapper that provides no useful behavior or distinction.
Compare these two types:
type DisplayName:
value: stringand:
type Percentage:
create(value):
require 0 <= value <= 100The first may still be worthwhile if DisplayName prevents confusion with other strings or establishes formatting rules. But if it is used once, has no invariant, cannot be confused with another value, and only adds .value everywhere, the abstraction may cost more than it saves.
A domain type earns its place when it does at least one meaningful job: it centralizes an invariant, prevents incompatible values from being mixed, gives important operations a natural home, or makes valid states substantially clearer.
The threshold depends on the codebase. A small script may be clearer with primitives and validation in one place. A long-lived application with the same concept crossing many modules gains more from giving that concept an explicit type.
Watch for rules that drift over time
Centralizing validation has a consequence: changing the type’s invariant changes what all new instances are allowed to represent.
Suppose Username initially allows 3 to 20 characters, and the product later raises the maximum to 30. Changing the constructor is straightforward for new values. Tightening a rule is harder. Existing stored values may violate the new constraint, and reconstructing them from persistence may start failing.
Before strengthening an invariant, ask what happens to historical data. You may need a migration, a compatibility path for old records, or separate validation rules for creating new data and reading existing data.
This is why “validate once” doesn’t mean “validation can never change.” It means the program has an explicit place where the validity contract is established, making changes to that contract easier to find and reason about.
Refactor one concept at a time
Primitive obsession is usually easier to remove incrementally than through a domain-model rewrite.
Start with a value that causes real friction: duplicated validation, frequent parameter mix-ups, unclear units, or conditionals scattered across callers. Introduce the domain type at one boundary, adapt nearby callers, and keep conversions explicit while the change moves through the codebase.
During a transition, old and new representations may coexist. That is acceptable if the conversion points are visible. What creates confusion is silently converting back to primitives throughout the middle of the system, because the invariant then disappears exactly where you wanted it to help.
Tests should focus on the contract the new type establishes: valid construction succeeds, invalid construction fails in the documented way, equality behaves as intended, and any domain operations preserve the invariant.
Use a domain type when it removes knowledge from callers
The most useful signal isn’t the number of strings or integers in a file. It’s how much domain knowledge callers must carry around to use them correctly.
If several callers repeatedly ask whether a value is valid, remember its unit, distinguish it from another primitive with the same representation, or coordinate fields that form one concept, a domain type can move that knowledge to a single boundary.
If a primitive already has one obvious meaning, is validated once, and creates no recurring ambiguity, leave it alone. The goal isn’t a codebase without primitives. The goal is to make meaningful constraints explicit where doing so reduces the amount developers have to remember.