Domain primitives to stop stringly typed bugs early
Domain primitives help teams replace loose ids, money values, and status flags with small types that stop common mistakes before they ship.

Why plain strings cause real bugs
A plain string looks harmless because it fits almost anywhere. That is the problem. The same type can hold a user ID, an email address, a price, an order status, a country code, or a coupon name, and the compiler treats them all as equally valid.
That makes simple mistakes look normal. If a function expects a user ID and someone passes an order ID, the code still runs. If a checkout flow expects a price in cents and gets "19.99" instead of "1999", nothing in the type system stops it.
A lot of bugs start with values like these. "user_123" and "order_123" both look like valid strings. "19.99" might mean dollars, euros, or raw text from a form. "paid", "Paid", and "payment_done" may all try to mean the same status. Even "true" and "false" become a mess when they are stored as text.
Reviews miss this all the time because the code looks fine at a glance. A copied line still has the right shape. Variable names sound close enough. When every field is a string, the reviewer has to keep the meaning in their head instead of reading it from the type.
Picture a small order flow. One function loads a customer by ID. Another fetches an order by ID. Both take a string. A developer copies a call, forgets to swap the variable, and sends orderId into the customer lookup. Test data can hide the mistake if both IDs use the same format.
Status flags create the same kind of trouble. One part of the app checks for "paid". Another writes "completed". A third uses "1" because it came from an older table. There is no syntax error, but the order lands in the wrong state and support gets the ticket.
Stringly typed bugs are annoying because they look small and act random. They pass review, slip through tests, and come back later as "weird edge cases." Most of them are not edge cases. The code is missing meaning.
What domain primitives are
A domain primitive is a small type that wraps one business value. It takes a plain value like a string or number and gives it a clear job, a name, and a few rules.
That sounds minor, but it changes how code behaves. A raw string can mean almost anything: a user ID, an order ID, an email, a country code, or a status. The program sees them all as just text, so it lets you mix them up.
function shipOrder(userId: string, amount: number, status: string) {}
shipOrder("ord_123", 49.99, "paid")
Nothing in that call tells the program that "ord_123" is the wrong kind of ID, that 49.99 needs a currency, or that "paid" may not be the status this function expects.
With domain primitives, those values become UserId, Money, and OrderStatus. Now the value carries its own rules. UserId can check the format when you create it. Money can keep amount and currency together, so nobody adds dollars to euros by accident. OrderStatus can allow only the states your business actually uses, instead of any random string.
In a typed language, the compiler catches many mixups before the code runs. If a function asks for UserId, passing OrderId should fail right away. In a dynamic language, you still get a real benefit because the constructor can reject bad input and your tests fail close to the source of the mistake.
This is why small types pay off early. They turn vague data into named values with boundaries. The code gets a little stricter, but it also gets easier to read. When you see Money(4999, "USD") or OrderStatus.Paid, you know what the value means and what the program allows.
Start with the values that break most often
Do not start by wrapping every string and number in your code. Start where mistakes already cost time. If support keeps seeing orders attached to the wrong customer, or finance keeps fixing totals by hand, those are your first targets.
IDs usually pay off first. A customer ID, order ID, invoice ID, and session ID can all look like "12345". The compiler cannot tell them apart if they are all plain strings. A small type like CustomerId or OrderId stops mixups before the code runs. One wrong parameter can create hours of cleanup, so this change often earns its keep fast.
Money should come next. Teams often pass amount and currency as separate fields, or worse, keep amount as a floating point number. That is how you end up adding 10 USD to 10 EUR or rounding 19.99 into 19.989999. A Money type keeps the amount and currency together and gives you one place to handle rounding, comparison, and display.
True or false flags deserve the same suspicion. A flag like isPaid looks simple until the business adds refunded, partially paid, chargeback, pending review, or failed. Then the flag becomes a trap. A named state such as PaymentStatus makes the code read like the business rule instead of a guess.
A good first batch is usually IDs that share the same shape but mean different things, money values that need amount and currency together, status fields that hide more than two real states, and any field that keeps showing up in support tickets, refunds, or manual rework.
That last group matters most. Pick the values people already complain about. If a mistake sends a package to the wrong address once a month, fix that before you wrap a harmless display name. Domain primitives work best when they remove pain you can already see.
A simple order flow example
Order flows are where loose values start to hurt. You have a customer ID, an order ID, a total, and a status. All four look harmless when they are plain strings, numbers, or flags.
createOrder(customerId: string, orderId: string, total: number, shipped: boolean)
That signature invites mistakes. A developer can swap customerId and orderId, and the code still runs.
createOrder(orderId, customerId, 19.99, false)
Nothing in that call says it is wrong. Both IDs are strings, so the compiler accepts them. The bug appears later, when support tries to find an order and gets a customer record instead, or when a refund goes to the wrong account.
Money breaks in a quieter way. 19.99 looks clear to a person, but the code only sees a floating point number. It does not know whether that value means USD, EUR, or store credit. If one service sends dollars and another reads euros, the price changes with no obvious error. Floating point math can also turn 19.99 into a slightly different value during tax or discount calculations.
Status flags create another class of bug. A boolean like shipped: true | false works only when the world has two states. Real orders do not. An order can be paid, packed, shipped, canceled, refunded, or returned. When a team stores only shipped = false, they hide several different situations inside one value. A canceled order and a refunded order both look the same.
Small types fix this by forcing the code to speak clearly:
createOrder(customerId: CustomerId, orderId: OrderId, total: Money, status: OrderStatus)
Now a swapped ID fails early. A Money type can store amount and currency together, often in cents, which avoids rounding trouble. An OrderStatus type can allow only real states, and your code can define which moves are valid. For example, canceled should not jump to shipped.
That is why domain primitives pay off fast. They turn silent mistakes into obvious ones, right where the code gets written.
How to introduce them step by step
Start with a quick scan of the raw values your app passes around every day. Look for anything that has business meaning but still moves through the code as a plain string, number, or boolean. OrderId, CustomerId, Money, Currency, Email, and OrderStatus are common trouble spots.
Do not try to replace everything in one pass. That creates a huge diff, slows reviews, and makes people push back. Domain primitives work better when you add one small type, ship it, and move to the next.
A practical rollout is simple. Write down the raw values that can cause a real business mistake. Pick one type that is easy to name and easy to validate. Put the rules inside the constructor or factory. Use it in the busiest code path first, then leave old low risk corners for later.
The validation part matters most. If Money can never be negative in one part of your system, do not check that rule in ten services and three controllers. Make the Money type reject bad input when you create it. After that, the rest of the code can trust it.
The busiest paths deserve attention first because bugs there cost more. Checkout, invoicing, refunds, auth, and status changes usually touch real users and real money. If an order flow mixes up OrderId and CustomerId once a week, fix that before you clean up an admin script nobody uses.
A small example makes this easier to picture. Say a team stores order status as a string. One handler writes "paid", another writes "Payed", and a third checks for "complete" instead. That bug can sit quietly for months. A tiny OrderStatus type closes that gap the day you add it.
Keep adapters around while you migrate. If one old report still expects a plain string, convert at the edge and move on. This kind of gradual change works well in startup systems. On oleg.is, Oleg Sotnikov often advises teams to fix the hot path first and clean the rest when the payoff is clear.
What to put inside each type
A small type should do one job: hold a value that the rest of the code can trust. If a type turns into a grab bag of helpers, people stop knowing why it exists.
Most domain primitives need only two things: the raw value and the rules that make that value valid. An OrderId may wrap a string and check that it is not empty. A Money type may wrap cents and block negative amounts if your order flow never allows them.
Keep the public shape tight. When another developer opens the type, they should see a few obvious methods and nothing more. In most cases, that means a way to create or parse the value, a way to read the value back, a way to compare two values, and maybe a display method if the type truly owns that format.
That last part matters more than it sounds. Formatting belongs in the type only when the rule is part of the domain, not just a UI choice. A Percentage type can safely render 15%. A Money type can format $12.50 only if your app uses one currency and one display style. If screens, countries, or reports need different formats, keep that logic outside.
Invalid input needs a clear failure path. Do not let bad data sneak in as an empty string, 0, or null and hope someone notices later. Pick one approach and stick to it: return a result with an error, or fail right at the boundary. Mixing both styles creates confusion.
Names help too. UserId.parse("abc") tells people what is happening. make, build, and handle usually do not.
One more rule saves a lot of mess: do not stuff unrelated behavior into the type. A Status type should not know how to send emails, map UI colors, or query the database. The type exists to protect one value and make wrong states harder to write.
Mistakes teams make
Teams often overcorrect. They discover domain primitives, get excited, and wrap every field in a custom type. That usually makes code harder to read without making it safer. If a field has no real rule, no special format, and no risk of mixups, a plain string or number is often fine.
A better rule is simple: create a small type when the value can break something. OrderId and CustomerId can get swapped. Money can lose cents or mix currencies. Status values can drift into states nobody meant to allow. A free text note usually does not need the same treatment.
Another mistake looks safer than it is. Teams keep raw strings everywhere, then add helper functions like parseId or validateStatus that still return strings. That hides the problem instead of fixing it. If every function still accepts a string, people can still pass the wrong thing.
Validation also goes wrong when teams spread rules across layers. The form checks one thing, the API checks another, and the database checks a third. Then nobody knows which rule is the real one. Put the domain rule in the type itself, and let the rest of the app call that same rule.
Vague names cause a lot of damage. A type called GenericId does not protect much, because developers can use it for anything. CustomerId and OrderId are better because they block accidental swaps. The same goes for fuzzy names like DataValue, StatusFlag, or MetaField. If the name does not tell you what the value means, the type will not help much.
Small types also need tests. Teams skip them because the code looks too simple to fail. That is usually a mistake. Parsing and equality checks deserve direct tests, especially for money, typed IDs, and status values. If "00123" and "123" should count as different, test that. If two money amounts need both amount and currency to match, test that too.
One quick example says it all. If an order service accepts GenericId, a developer can pass a customer ID and the compiler stays quiet. If the service accepts OrderId, that bug stops early. The type should make the wrong move awkward and the right move easy.
A quick review checklist
A small type should remove one common mistake, not add noise. When you review a model, ask a simple question for each field: can this type stop a real bug before the code runs?
- If two values look the same in code but mean different things, give them different types.
- If you store money, keep the amount, currency, and rounding rule together.
- If a field tracks status, name the real states instead of hiding them behind a true or false flag.
- Reject bad input at the edge, when you create the type.
- If a new teammate cannot tell what the type protects in a minute, the name or boundary is too vague.
A quick thought experiment helps. Picture a checkout flow where an order starts as pending, moves to paid, and later becomes refunded. If your code can compare a customer ID with an order ID, accept money with no currency, or treat "false" as both unpaid and failed, the model still hides meaning.
That is where domain primitives earn their keep. They make the rules visible in the type instead of leaving them scattered across comments, controller code, and database checks.
Keep each type small. Most of them wrap one raw value, validate it once, and offer a few obvious operations. If a type starts to collect half the business rules in your app, split it and keep the boundary tight.
A good review often ends with one useful sentence: "This field can no longer be confused with that one." If you can say that for IDs, money, and statuses, you are on the right track.
Where to go next
Pick one flow that breaks in small, annoying ways and refactor only that part this week. Orders, billing, user signup, and access control are common candidates because loose IDs, money fields, and status flags tend to pile up there first.
Keep the first pass small. Turn one raw string ID into a typed ID. Wrap one money value so amount and currency travel together. Replace one open text status field with a small type that accepts only valid states. That is enough to show whether domain primitives pay off in your codebase.
After the change, watch a few plain signals: support issues tied to wrong values or mixed IDs, review comments that ask "what does this string mean?", time spent tracing bugs across controllers and jobs, and test cases you can delete because the type now blocks bad input early. You do not need a dashboard for this. A short note in your sprint doc is enough.
Then write one team rule and keep it short. New business values should not start life as raw strings if they have rules, meaning, or formatting. IDs, money, status, and similar fields get their own small types once they show up in more than one place.
This matters even more for teams that move fast. When reviews get shorter and bug reports get more boring, people usually stop arguing about the pattern and start using it on their own.
If your team wants outside input, Oleg Sotnikov advises startups and small companies on practical architecture changes like this. The goal is usually the same: fix one risky path, set a clear rule, and build from there.
That is a better next step than opening a broad cleanup ticket. Small types earn trust when they remove one real bug at a time.
Frequently Asked Questions
What is a domain primitive?
A domain primitive is a small type for one business value, like CustomerId, Money, or OrderStatus. It wraps a raw string or number and checks the rules once, so the rest of your code works with a value you can trust.
Where should I start first?
Start with values that already cause cleanup, refunds, or support tickets. IDs, money, and status fields usually pay off first because teams mix them up often and the bugs cost real time.
Do I need a custom type for every string?
No. Wrap the values that carry business meaning or break things when someone mixes them up. A free text note usually does not need its own type, but OrderId and Money often do.
Which fields benefit most from this?
CustomerId, OrderId, InvoiceId, Money, Currency, Email, and status fields are common first picks. They often share the same raw shape, so plain strings make mistakes look valid.
How does this help in TypeScript?
It lets the compiler stop obvious mixups before runtime. If a function asks for CustomerId, passing OrderId fails right away instead of turning into a bug that shows up later.
Are domain primitives worth it in a dynamic language?
You still get value from them. The constructor or parser can reject bad input at the edge, and your tests fail near the source instead of after the bad value spreads through the app.
Should I store money as a float?
No. Store money as an integer amount like cents and keep the currency with it. That avoids rounding trouble and stops code from adding 10 USD to 10 EUR like they mean the same thing.
How do I add domain primitives without a big rewrite?
Keep the rollout small. Add one type in one busy flow, convert old values at the edges, ship it, and move on. That keeps reviews simple and shows the payoff early.
What should I put inside each type?
Keep one raw value and the rules that make it valid. Add a clear way to create it, read it, and compare it. Do not pack in database calls, UI colors, or unrelated app logic.
What mistakes should I avoid?
Teams often wrap too much, use vague names like GenericId, or keep returning plain strings from helper functions. Another common miss is spreading validation across forms, APIs, and database rules instead of putting it in one place.