TypeScript Domain Models for Business Software

Design TypeScript domain models that represent business states, enforce valid transitions, validate runtime data and keep persistence, APIs and user interfaces from leaking into core rules.

TypeScript domain models for business software should make invalid business states difficult to construct and consequential transitions easy to review. A domain model is not a mirror of a database table or a collection of interface shapes shared everywhere. It is the code-level vocabulary for customers, cases, orders, approvals, entitlements or projects, including the invariants that decide what each object means and which changes are allowed.

This guide complements TypeScript architecture fundamentals, the custom software architecture guide and the OpenAPI 3.1 migration guide. It focuses on a practical boundary: TypeScript checks trusted program values at compile time, while runtime validation is still required for network, storage, form and queue data.

Model the business language, not the screen

Begin with events and decisions from the workflow. Ask what makes an order placeable, an invoice payable, a case assignable or a subscription active. Name concepts in the language used by operations and policy owners. A screen may combine data from several concepts, while one concept may appear on many screens. If UI forms become the model, optional fields spread and the code loses the distinction between “not collected,” “not applicable” and “not permitted.”

Define identity and value objects deliberately. A CustomerId and InvoiceId may both serialize as strings but should not be interchangeable in core functions. Money needs amount, currency and rounding policy; a date-only business deadline should not be silently treated as an instant. Lightweight branded types or constructor functions can preserve these distinctions. Keep formatting and localization at presentation boundaries so domain comparisons use normalized values.

ConceptWeak representationDomain-oriented representation
Workflow statestatus: stringDiscriminated union of permitted states
Moneyamount: numberMinor units or decimal plus currency
Identityid: string everywhereDistinct customer, project and invoice IDs
Timedate: stringExplicit instant, local date or interval
PermissionisAdmin: booleanAuthorized operation evaluated for subject and resource

Represent state with discriminated unions

Use a discriminant when each state carries different valid data. A draft approval may be editable, a submitted approval requires a submission time, and a decided approval requires decision evidence. One interface containing many optional properties permits nonsensical combinations such as a draft with a rejection reason. A union lets TypeScript narrow the variant after checking its state and makes code review show the complete set of cases.

Make switches exhaustive. A helper accepting never in the default branch turns a new variant into compile-time work for every decision that must handle it. Exhaustiveness does not prove the business rule is correct, but it reveals forgotten branches. Use strict compiler settings and consider exactOptionalPropertyTypes so absence is not silently equivalent to a property explicitly set to undefined. Adopt flags incrementally where legacy code makes an immediate change impractical.

type Approval =
  | { state: 'draft'; id: ApprovalId }
  | { state: 'submitted'; id: ApprovalId; submittedAt: Date }
  | { state: 'approved'; id: ApprovalId; decision: DecisionEvidence }
  | { state: 'rejected'; id: ApprovalId; decision: DecisionEvidence }

function assertNever(value: never): never {
  throw new Error('Unhandled approval state')
}

Put business transitions behind explicit functions

Do not let arbitrary code mutate a public status field. Expose operations such as submitApproval, approveInvoice or suspendSubscription that accept the current state, command data and decision context. Validate preconditions and return a result describing the new state or a typed rejection. This makes authorization, invariants and emitted events visible at one boundary and prevents controllers, jobs and UI handlers from each implementing slightly different rules.

Separate pure decision logic from effects. A pure function can decide whether a transition is allowed and which domain event follows. An application service loads current state, checks current authorization, calls the decision, persists it atomically where possible and publishes effects through a reliable mechanism. Retries must use command or event identity. Types cannot prevent two workers from acting on stale state, so persistence needs version checks, transactions or another concurrency strategy.

LayerResponsibilityShould not own
DomainInvariants, decisions and state transitionsHTTP, database queries or UI labels
ApplicationUse-case orchestration and transaction boundaryVendor transport details
AdapterAPI, queue, database and provider translationIndependent business policy
PresentationInput assistance and view compositionAuthoritative authorization
ContractVersioned external shape and compatibilityInternal entity implementation

Validate every untrusted runtime boundary

Type annotations disappear at runtime. JSON parsed from an API, database, local storage, message or form is unknown until validated. Parse at the edge with a schema or explicit validator, reject unexpected structure according to policy and transform accepted input into domain values. Validation should cover shape, ranges, formats and cross-field rules. Keep the raw payload available only where incident or audit requirements justify it, and protect sensitive content.

TypeScript domain boundary flow
Types become useful business controls when runtime data is parsed before explicit domain decisions and external effects.

Avoid asserting external data with as DomainType; that asks the compiler to trust the developer without checking the value. Generate types from a contract or generate a contract from code only when one source is clearly authoritative and drift is tested. OpenAPI 3.1 aligns schema modeling with JSON Schema, but wire compatibility still requires examples and consumer tests. Translate provider-specific nulls, enums and timestamps before they enter the domain.

Keep persistence and API models separate when meanings differ

A database row optimizes storage and querying; an API model is a compatibility promise; a domain object expresses current rules. They may coincide for a simple record, but forcing one shape across all layers creates leakage. Persistence often contains technical columns and nullable migration states. APIs may retain deprecated fields. Read models may denormalize several entities. Map explicitly at boundaries where these concerns differ, and test mappings with representative historical data.

Do not create abstraction for its own sake. A small CRUD reference table can remain a validated record. Rich modeling earns its cost where state, money, permissions, deadlines or cross-system effects matter. Use value objects and transitions around high-consequence behavior first. Document exceptions where legacy states cannot satisfy current invariants; migration code can represent an invalid historical record without making that state constructible in new operations.

Test the model as a business contract

Write example tests for every transition and failure reason, then add property-based tests where combinations are large. Test that money remains balanced, terminal states cannot reopen without an explicit operation, and permissions are evaluated server-side. Contract tests should feed real provider examples through decoders. Migration tests should load old rows and prove that mappings preserve meaning. Compile-time type tests can protect inference and prevent accidental widening of important unions.

Review model changes with business and operations owners when vocabulary or authority changes. A new union variant is not merely a developer concern if reports, integrations and support procedures must recognize it. Release schema changes compatibly, monitor unknown variants at boundaries and keep telemetry on rejected commands. The model is healthy when it helps teams explain behavior and change policy safely, not when it maximizes the number of advanced type features.

Use a domain-model review checklist

Before merging a consequential model change, ask whether names match business language, variants are mutually exclusive, invariants have one enforceable home, transitions preserve authorization and concurrency, and external input is parsed before construction. Review serialization and historical-data compatibility separately from the in-memory type. Require examples for every new state and rejection reason, plus a migration or fallback for existing records that cannot satisfy the new rule.

At release, inspect downstream contracts, analytics, support tools and runbooks for assumptions about the changed state. Add telemetry for unknown variants and rejected transitions, then remove it only when the migration window closes. This operational review prevents a locally elegant union from breaking consumers that were not represented in the compiler graph and makes the domain model a shared change contract rather than an internal coding preference.

Keep a short decision record for modeling choices that are not obvious from code: why two similar identifiers remain distinct, which clock and timezone govern a deadline, why a transition is reversible, or why an external enum is translated instead of reused. Link the record to tests and contract versions. Future maintainers can then change the model from business evidence rather than preserving an accidental implementation because nobody remembers its original constraint.

Prefer a small, consistent domain vocabulary over clever local aliases. Enforce import boundaries so UI and infrastructure modules depend on the domain, while core rules do not import frameworks. Run the type checker, runtime decoder tests and contract tests in continuous integration. This combination catches different classes of failure and makes architectural intent executable for every change.

Key takeaways

  • Model workflow decisions and invariants rather than database or screen shapes.
  • Use discriminated unions and exhaustive handling for mutually exclusive states.
  • Route changes through explicit transition functions and concurrency controls.
  • Parse external values at runtime before constructing domain objects.
  • Separate domain, persistence and contract models when their meanings diverge.

Frequently asked questions

Should every entity be a class?

No. Plain immutable objects plus constructor and transition functions are often clearer. Classes are useful when encapsulation improves the API, but JavaScript private fields and TypeScript private modifiers have different runtime implications that should be understood.

Are TypeScript types enough for API validation?

No. They provide compile-time checks for code, not runtime proof about received JSON. Validate unknown input against an approved schema and return structured errors before it reaches domain logic.

Should domain events contain the whole entity?

Usually publish the stable facts consumers need, with event identity, type, subject, time and schema version. Full snapshots increase exposure and coupling. Consumers needing current detail can use an authorized query.

How strict should tsconfig be?

New business-critical code benefits from strict mode. For an existing system, enable checks in a planned sequence, measure errors and avoid mass assertions that hide uncertainty. The goal is stronger evidence, not a green build obtained by bypassing checks.

Conclusion

TypeScript domain modeling is valuable when it makes the business rules easier to see and invalid change harder to ship. Precise states, explicit transitions, runtime validation and clean boundaries create that value. Used selectively around consequential behavior, these practices let a business system evolve without turning every database, API and screen change into the same risky edit.

Continue with related articles

AsyncAPI 3 Event Contracts for Runtime Governance

Adopt AsyncAPI 3 as an executable event contract by separating channels, operations, and messages; governing bindings and schemas; testing compatibility; and linking runtime evidence to ownership.

Software Engineering · 15 min