Technical note

Model the Business, Not the Implementation

Optional values are useful when absence exists in the business. They become dangerous when they hide workflow state, API awkwardness, database looseness, or compiler discomfort.

Software engineering is not writing code. It is making good engineering decisions.

One decision that looks small, but quietly shapes an entire system, is this:

Should this value be optional?

Modern languages have made that decision feel easy.

TypeScript has optional properties and undefined. Python has Optional. Java has Optional. Kotlin has nullable types. Rust has Option. Swift has Optional.

These tools are valuable. They make absence visible, and they force the caller to acknowledge that a value may not exist.

But because they are convenient, they get used for the wrong job.

The compiler says a value might be missing. The database column allows NULL. The API sometimes omits a field. The legacy function occasionally returns undefined.

So we reach for Option. That silences the immediate problem — but using Option to quiet the compiler is a modeling smell: it can turn an implementation problem into a domain rule.

Not every missing value means the same thing. Before making a field optional, ask a more important question:

Where does the absence come from?

In practice, absence comes from one of three places — and the answer to each is already the rule:

Reality → optional is honest. Workflow → name the state. Implementation → fix the boundary.

The rest of this article is why.

Reality: optional is honest

When the absence comes from reality, the business itself allows the value to be missing.

interface Customer {
  middleName?: string;
}

Some customers do not have a middle name. The absence is not a bug, an unfinished process, or a technical limitation. It is part of the real world, and the optional field expresses something true about the domain.

Other examples are similar:

  • Apartment number
  • Promotion code
  • Secondary phone number
  • Passport expiry date for someone without a passport
  • Date of death

In these cases the business concept itself says, “this may not exist.” That is the good use of Option: it models real absence.

Workflow: name the state

When the absence comes from workflow, the value is not truly missing — it simply has not reached its final state yet. This is where optional values start to mislead.

Consider a user verification flow:

interface User {
  verified?: boolean;
}

At first glance this seems reasonable. But what does undefined mean?

  • Verification has not started?
  • Verification is pending?
  • Verification failed?
  • Verification expired?
  • The verification service is unavailable?
  • The old data was never migrated?

Those are not the same state. The problem is not that verified might be missing — it is that the workflow has never been named.

A better model is explicit:

type VerificationStatus =
  | "not_started"
  | "pending"
  | "approved"
  | "rejected"
  | "expired";

interface User {
  verificationStatus: VerificationStatus;
}

Now the model says what the business actually knows, and that changes the decisions available to the system:

  • The UI can show the right message.
  • The backend can enforce the right transition.
  • Analytics can count the right funnel stage.
  • Support can see what happened.
  • Tests can cover each state deliberately.

When workflow is hidden behind optionality, every caller has to rediscover the meaning of absence. One treats undefined as “not started,” another as “not verified,” another retries the provider, another blocks the user. The model did not make the business decision, so the decision leaked everywhere.

Implementation: fix the boundary

When the absence comes from implementation, the value is uncertain only because of a technical detail — not a business concept:

  • The compiler complains.
  • The database allows NULL.
  • The API response is inconsistent.
  • A migration has not backfilled old records.
  • A legacy helper returns undefined.
  • Refactoring would be annoying.

These are real problems, but they are not domain concepts. If the business requires an email address for an active account, this is a weak model:

interface Account {
  id: string;
  email?: string;
}

Maybe the column is nullable, maybe old rows are messy, maybe the object is constructed before the email is loaded. But if an active account must have an email, the domain model should say so:

interface ActiveAccount {
  id: string;
  email: string;
}

The messy part still has to be handled — the point is not to pretend it does not exist, but to handle it at the boundary where it belongs:

function toActiveAccount(row: AccountRow): ActiveAccount {
  if (!row.email) {
    throw new Error(`Account ${row.id} is missing required email`);
  }

  return {
    id: row.id,
    email: row.email,
  };
}

Throwing is one option; returning a validation result, quarantining the row, or triggering a repair workflow are others. The design choice matters less than keeping the mess at the boundary. Now the domain object is honest: it represents a valid active account, and the database weirdness is contained where it originates.

When implementation uncertainty leaks into the domain model instead, it spreads. Every piece of code becomes responsible for remembering which optional fields are truly optional and which ones are “optional because the system is messy.” That is how small compromises become architecture.

The engineering principle

Your domain model should describe the business, not the limitations of your implementation.

A type is not just a compiler instruction. It is a statement about the system. When you write email?: string, you are claiming that a valid instance of this model may have no email. If that is true in the business, good. If it is only true because of loading order, migration history, API inconsistency, or a nullable column, the model is now lying — and once the model lies, every caller has to compensate.

Optional values are not bad. They are precise when they represent real absence. They blur meaning only when they stand in for a workflow state or an implementation detail. Each of those three sources deserves a different design response — and the syntax is the last step. The first step is deciding what the business actually says.