Many bugs begin with a value that is technically valid for its programming-language type but invalid for the job it represents. A quantity is negative. A percentage is passed as 20 where another function expects 0.20. Two string arguments are swapped because both look identical to the type system.
The problem is not that strings and numbers are poor types. They are useful building blocks. The problem appears when a primitive value has accumulated rules or meaning that the primitive cannot express.
A small domain type gives that meaning a home. Instead of passing a raw number that callers must remember is a percentage between 0 and 100, code can pass a DiscountPercent that represents only valid discount percentages. This article explains how to recognize that boundary, introduce it gradually, and avoid creating tiny types that add ceremony without useful protection.
Start with the meaning, not the storage
A useful mental model is:
primitive = how a value is stored
domain type = what the value means to this programConsider a function that calculates a discounted price:
function discounted_price(price, discount):
return price * (1 - discount / 100)The arithmetic is straightforward, but the parameters hide several assumptions. Is discount expressed as 20 or 0.20? Can it be negative? Can it exceed 100? What unit does price use?
Comments and parameter names can explain those rules, but they do not make invalid values impossible to pass.
Suppose this application consistently represents discounts as percentages from 0 through 100. A small domain type can capture that rule:
DiscountPercent:
create(value):
if value < 0 or value > 100:
return InvalidDiscount
return DiscountPercent(value)
fraction():
return value / 100Now the calculation can state what it needs:
function discounted_price(price, discount_percent):
return price * (1 - discount_percent.fraction())The important change is not the extra wrapper. It is the movement of a rule. The valid range and conversion convention no longer live in every caller’s memory. They belong to the concept that requires them.
A domain type should protect an invariant
An invariant is a condition that should remain true for every valid instance of a concept. For DiscountPercent, the invariant is that its value stays between 0 and 100.
If callers can freely construct DiscountPercent(-30) or mutate its internal value to 500, the type communicates intent but does not provide much protection. A stronger design makes creation go through validation and prevents later operations from breaking the invariant.
The exact mechanism depends on the language. It might be a private constructor, a factory function, a validated record, or an immutable value object. The general engineering rule is independent of those mechanisms:
Once code receives a valid domain value, it should not need to repeat the validation that defines that value.
This changes the reasoning burden. Without the type, every function accepting a raw discount must decide whether it trusts the caller, validates again, or risks invalid arithmetic. With a validated domain type, functions can rely on the invariant within the boundary where that guarantee holds.
That last condition matters. Data arriving from a database, configuration file, message queue, HTTP request, or other external source is still untrusted with respect to the domain type. It must be parsed and validated before the application treats it as a valid DiscountPercent.
Use different types when values are not interchangeable
Validation is only one reason to introduce a domain type. Another is preventing accidental substitution between values that share a primitive representation.
Imagine this function:
function transfer(from_account_id, to_account_id, amount):
...If account identifiers and transaction identifiers are all strings, a caller can accidentally pass a transaction ID where an account ID belongs. The string itself may be perfectly valid, so ordinary string validation cannot detect the mistake.
Distinct types can encode non-interchangeability:
AccountId
TransactionIdA function accepting AccountId communicates a stronger contract than one accepting string. In languages with static type checking, this distinction can allow the type checker to reject some substitutions before execution. In dynamically typed systems, the benefit depends on the runtime checks and conventions used, but the type can still centralize parsing and make intent explicit.
Do not infer more than the type guarantees. An AccountId can establish that a value has the shape and meaning of an account identifier. It does not prove that the referenced account exists, belongs to the current user, or is allowed to participate in a transfer. Those are separate rules that may require current application state.
Put behavior where the required knowledge lives
A domain type becomes more useful when it owns small operations that depend directly on its representation or invariant.
For example, callers should not repeatedly convert percentages themselves:
subtotal * (1 - discount.value / 100)If the conversion belongs to DiscountPercent, callers can ask for the meaning they need:
subtotal * (1 - discount.fraction())This reduces duplicated knowledge. If the representation later changes from an integer percentage to basis points, fewer callers need to know.
There is a boundary, however. Do not move unrelated application decisions into the type merely because they mention a discount. A rule such as “premium customers may receive this promotion” depends on customer and promotion policy, not only on what a discount percentage means. That decision belongs at a level that has the required context.
A useful test is to ask: Could this operation be decided correctly from this value and its intrinsic rules alone? If yes, it may belong on the domain type. If it requires other entities, current state, permissions, or workflow context, keep it elsewhere.
Introduce the type at one boundary first
Replacing every raw value across a mature system in one change can create a large migration with little immediate benefit. A smaller path is usually easier to review and reverse.
Suppose an application receives this request:
{
"discount": 20
}The external contract does not need to change just because the internal model improves. The request boundary can convert the raw number:
raw request
|
| parse and validate
v
DiscountPercent
|
| application logic
v
raw responseThis creates a clear transition point. Outside the application boundary, the value follows the transport format. Inside, code can rely on the domain invariant.
A practical migration can proceed in small steps:
- Identify one operation where primitive misuse creates real confusion or repeated validation.
- Introduce the domain type and tests for its invariant.
- Convert raw input at the nearest sensible boundary.
- Change the selected operation to accept the domain type.
- Move only behavior that genuinely belongs to the concept.
- Expand usage when later changes make the benefit worthwhile.
Temporary conversion code is acceptable during an incremental migration. The goal is to make the boundary clearer, not to produce a repository-wide rewrite immediately.
Decide where invalid input becomes an error
A domain type needs an explicit creation policy. Consider DiscountPercent again. Invalid input could be handled in several legitimate ways:
create(120) -> error
try_create(120) -> no value
parse("20") -> DiscountPercent(20)The right shape depends on how failure is expected to occur. User input commonly needs a recoverable validation result. A programmer-only constructor may treat an invalid constant as a programming error. Parsing text has additional failure cases because the text may not even represent a number.
What matters is that failure is visible in the interface. Silently changing invalid values is usually a different semantic decision. For example, converting 120 to 100 is not validation; it is clamping. Clamping may be correct for some domains, but it should be an intentional rule because it changes the caller’s value.
Also decide how the type behaves when reconstructed from persisted data. If old records can violate today’s invariant, blindly constructing the new type can turn a refactoring into a production migration problem. Options include cleaning the data first, supporting an explicit legacy path temporarily, or choosing an invariant compatible with existing valid records. The right choice depends on the system’s data guarantees.
Avoid types that only rename a primitive
Not every primitive deserves a wrapper.
This type adds little by itself:
FirstName:
value: stringIf FirstName has no meaningful invariant, no distinct operations, and little risk of being confused with another value, introducing it may increase conversions and API surface without reducing mistakes.
A domain type is easier to justify when at least one of these pressures is present:
- the value has validation repeated in several places;
- callers must remember a unit, range, format, or normalization rule;
- values with the same primitive representation are easy to swap accidentally;
- operations repeatedly expose representation details;
- a change to representation would otherwise affect many callers.
These are signals, not a scoring system. A frequently used UserId may deserve a distinct type primarily to prevent identifier mix-ups even if its internal representation is just a string. Conversely, a local integer used once as a loop count probably needs no domain abstraction.
Keep units explicit
Numbers are especially prone to hidden meaning. A bare 5000 might mean milliseconds, bytes, cents, metres, or retries in a synthetic test.
Domain types can make unit conversions explicit:
Timeout:
from_milliseconds(value)
from_seconds(value)
as_milliseconds()The type does not make arithmetic automatically correct. You still need to define rounding, overflow behavior, supported ranges, and conversion semantics where they matter. Its value is that those decisions have one visible location instead of being scattered through call sites.
Money deserves particular care. A Money type needs more than a numeric wrapper if the application supports multiple currencies, rounding rules, or operations across currency boundaries. A small domain type can improve the model, but it does not remove the need to define those domain rules explicitly.
Know what the type cannot guarantee
Domain types are strongest for rules that depend only on the value itself or on stable construction-time information. They are weaker for rules that depend on changing external state.
An EmailAddress type might guarantee that input passed whatever syntactic policy the application chose. It cannot guarantee that the mailbox currently exists or that a user still controls it.
An AvailableUsername type is even more problematic if availability can change between checking and saving. Treating a time-sensitive observation as a permanent property of a value can create a false guarantee. The final write may still need concurrency control or conflict handling.
Ask whether an invariant remains true without consulting mutable external state. If it does not, a workflow check or stateful operation is often a more accurate model than a value type.
Watch the serialization boundary
Domain types are internal modeling tools unless an external contract explicitly defines them. Libraries and frameworks may not know how to serialize a custom type automatically, and allowing infrastructure concerns to dictate the domain API can recreate the coupling the type was meant to reduce.
Prefer deliberate conversion at boundaries:
JSON number <-> DiscountPercent <-> application logicThis keeps transport representation and domain meaning separate. It also makes compatibility decisions visible. If an API must continue accepting integer percentages, the internal representation can still change without forcing clients to change with it.
The same principle applies to persistence. A database column can remain an integer while application code uses DiscountPercent, provided the persistence boundary converts between the two and handles invalid stored values according to an explicit policy.
When a primitive is the simpler design
Keep the primitive when its meaning is obvious, local, and unconstrained. A private helper that receives an index used only for array traversal probably does not need an ArrayIndex class. A one-off formatting function may reasonably accept a string if the caller already owns the relevant validation.
The cost of a domain type includes naming, construction, conversion, tests, and an additional concept for developers to learn. That cost is worthwhile when it removes repeated knowledge or prevents meaningful mistakes. It is waste when the wrapper merely restates the primitive’s name.
The decision is therefore not “primitive or object-oriented design.” The useful question is: Does this value carry domain rules or identity that callers are currently forced to remember?
If the answer is yes, a small domain type can move those rules from convention into code. If the answer is no, the primitive is often the clearer abstraction.
Conclusion
Primitive values are good storage building blocks, but they cannot express every distinction an application cares about. When a string or number accumulates validation, units, conversion rules, or a risk of being confused with another value, a domain type can make that meaning explicit.
Start small. Choose one value whose rules are already causing repeated checks or mistakes. Validate it at a clear boundary, preserve its invariant inside the application, and move only behavior that belongs to the concept itself.
The goal is not to wrap every primitive. It is to stop making callers repeatedly remember rules that the software can represent directly.