Skip to content

Best Practices ​

Domain Models ​

Each application contains a set of core concepts and rules that make it distinct from others. A banking app prevents you from withdrawing more than your balance. An e-commerce app won't let you apply more than one discount per order. A social platform might restrict who can see your posts. While these constraints (also called business logic or business rules) often end up scattered across the codebase, a good practice is to bring them together into so-called domain models.

A domain model is a specific kind of a model with the following characteristics:

  • Represents a core business concept.

    Uses the same language that business people use when talking about the system (ubiquitous language). For example BankAccount with canWithdraw may be a domain model in a banking app while Serializer likely won't be. In other words, if you show non-technical people only the domain models they should clearly understand what your system is about.

  • Doesn't depend on anything except other domain models.

    A domain model is your system in its purest form. It focuses on describing concepts and rules without any knowledge of what database will be used, how the logging works, where the app will run (client or server), etc. Because of that, the only dependencies (imports) you should see in domain models are other domain models (e.g. TodoList may depend on TodoItem) and pure utility functions (e.g. from libraries like lodash).

Below you can see some examples of domain (βœ…) and non-domain (❌) models for an e-commerce app.

typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
export type
ProductId
=
InferModelOutput
<typeof
ProductId
>;
export const
ProductId
=
createModel
(
z
.
uuid
().
brand
('ProductId'));
export type
Product
=
InferModelOutput
<typeof
Product
>;
export const
Product
=
createModel
(
z
.
object
({
id
:
ProductId
.
schema
,
name
:
z
.
string
().
min
(1),
price
:
z
.
number
().
positive
(),
quantity
:
z
.
int
().
nonnegative
(),
isActive
:
z
.
boolean
(),
}), {
canBePurchased
,
applyDiscount
,
isOutOfStock
,
}, ); function
canBePurchased
(
product
:
Product
,
quantity
: number) {
return
product
.
isActive
&&
product
.
quantity
>=
quantity
;
} function
applyDiscount
(
product
:
Product
,
discountPercent
: number) {
return { ...
product
,
price
:
product
.
price
* (1 -
discountPercent
/ 100),
}; } function
isOutOfStock
(
product
:
Product
) {
return
product
.
quantity
=== 0;
}
typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
import {
ProductId
} from './product';
export type
OrderItemId
=
InferModelOutput
<typeof
OrderItemId
>;
export const
OrderItemId
=
createModel
(
z
.
uuid
().
brand
('OrderItemId'));
export type
OrderItem
=
InferModelOutput
<typeof
OrderItem
>;
export const
OrderItem
=
createModel
(
z
.
object
({
id
:
OrderItemId
.
schema
,
productId
:
ProductId
.
schema
,
quantity
:
z
.
int
().
positive
(),
unitPrice
:
z
.
number
().
positive
(),
}), {
getTotalPrice
,
}, ); function
getTotalPrice
(
orderItem
:
OrderItem
) {
return
orderItem
.
quantity
*
orderItem
.
unitPrice
;
}
typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
import {
OrderItem
} from './order-item';
export type
OrderId
=
InferModelOutput
<typeof
OrderId
>;
export const
OrderId
=
createModel
(
z
.
uuid
().
brand
('OrderId'));
export type
Order
=
InferModelOutput
<typeof
Order
>;
export const
Order
=
createModel
(
z
.
object
({
id
:
OrderId
.
schema
,
customerId
:
z
.
uuid
().
brand
('CustomerId'),
items
:
z
.
array
(
OrderItem
.
schema
),
status
:
z
.
enum
(['pending', 'confirmed', 'shipped', 'cancelled']),
createdAt
:
z
.
date
(),
}), {
getTotalPrice
,
canBeCancelled
,
}, ); function
getTotalPrice
(
order
:
Order
) {
return
order
.
items
.
reduce
(
(
total
,
item
) =>
total
+
OrderItem
.
getTotalPrice
(
item
),
0, ); } function
canBeCancelled
(
order
:
Order
) {
return
order
.
status
=== 'pending' ||
order
.
status
=== 'confirmed';
}
typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
import {
Product
} from './product';
// Doesn't follow the first rule of domain models. // Shape of the products API endpoint response isn't // a business concept but a technical detail. export type
ProductsApiResponse
=
InferModelOutput
<typeof
ProductsApiResponse
>;
export const
ProductsApiResponse
=
createModel
(
z
.
object
({
data
:
z
.
array
(
z
.
object
({
id
:
z
.
string
(),
title
:
z
.
string
(),
price
:
z
.
number
(),
quantity
:
z
.
number
(),
is_active
:
z
.
boolean
(),
}), ),
pagination
:
z
.
object
({
total
:
z
.
int
().
positive
(),
page
:
z
.
int
().
positive
(),
limit
:
z
.
int
().
positive
(),
}), }), {
toProducts
,
}, ); // It's usually a good practice to convert // non-domain models into domain ones. function
toProducts
(
data
:
ProductsApiResponse
):
Product
[] {
return
data
.
data
.
map
((
product
) =>
Product
.
from
({
id
:
product
.
id
,
name
:
product
.
title
,
price
:
product
.
price
,
quantity
:
product
.
quantity
,
isActive
:
product
.
is_active
,
}), ); }
typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
// Doesn't follow any of the domain model rules // Shape of database entities isn't a business concept. // It depends on the database client. export type
OrderItemEntity
=
InferModelOutput
<typeof
OrderItemEntity
>;
export const
OrderItemEntity
=
createModel
(
z
.
object
({
id
:
z
.
uuid
(),
product_id
:
z
.
uuid
(),
quantity
:
z
.
int
().
positive
(),
unit_price
:
z
.
number
().
positive
(),
created_at
:
z
.
date
(),
updated_at
:
z
.
date
(),
}), {
save
,
}, ); async function
save
(
orderItemEntity
:
OrderItemEntity
):
Promise
<void> {
await
db
.
query
(
saveOrderItemSql
,
Object
.
values
(
orderItemEntity
));
}

Why Use Domain Models? ​

🎯 Everything in One Place

Instead of having your business rules scattered across different files, domain models keep them all in one place.

πŸ—£οΈ Speaks Business Language

Domain models use the same words your business team uses. When they ask you "When the order can be cancelled?" your code literally has a canBeCancelled() method that answers this question. This makes it much easier for developers and business people to understand each other and spot bugs.

πŸ“– Self-Documenting Code

Looking at a domain model immediately tells you what your system does and what rules it follows. New team members can understand the core business logic just by reading the domain models, without diving into complex infrastructure code.

πŸ§ͺ Easy to Test

Since domain models don't depend on anything except other models, you can test your business logic in isolation. This makes tests faster, more reliable, and easier to understand.

πŸ”„ Reusable Everywhere

Once you have a domain model, you can use it anywhere in your app, ensuring business logic stays consistent across multiple different contexts.

Model Composition ​

In some cases you may wonder if you should merge multiple models into one or move some model property to a standalone model. While there is no one-size-fits-all answer, here are some guidelines to help you make the right decision:

1. Check the model methods ​

As a rule of thumb, all model methods should receive (or return) the model instance. While not using all model's properties by a method is fine, situations where a method uses only one property are a good indicator that the property may benefit from a model of its own.

typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
export type
UserCredentials
=
InferModelOutput
<typeof
UserCredentials
>;
export const
UserCredentials
=
createModel
(
z
.
object
({
userId
:
z
.
uuid
().
brand
('UserId'),
email
:
z
.
email
(),
hashedPassword
:
z
.
string
().
brand
('HashedPassword'),
}), {
verifyPassword
,
}, ); // It uses only one model property. // It's a sign `hashedPassword` may be extracted to a separate model. function
verifyPassword
(
user
:
UserCredentials
,
plainPassword
: string) {
return
hashPassword
(
plainPassword
) ===
user
.
hashedPassword
;
}
typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
export type
HashedPassword
=
InferModelOutput
<typeof
HashedPassword
>;
export const
HashedPassword
=
createModel
(
z
.
string
().
brand
('HashedPassword'), {
verify
,
}); function
verify
(
hashedPassword
:
HashedPassword
,
plainPassword
: string) {
return
hashPassword
(
plainPassword
) ===
hashedPassword
;
} export type
UserCredentials
=
InferModelOutput
<typeof
UserCredentials
>;
export const
UserCredentials
=
createModel
(
z
.
object
({
userId
:
z
.
uuid
().
brand
('UserId'),
email
:
z
.
email
(),
hashedPassword
:
HashedPassword
.
schema
,
}), );

2. Consider domain boundaries ​

Models should align with a single business domain. If two pieces of data are related to different areas of your system, they should probably be separate models.

typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
// This model combines multiple unrelated business concepts: // profile display, preferences, and subscription. export type
User
=
InferModelOutput
<typeof
User
>;
export const
User
=
createModel
(
z
.
object
({
id
:
z
.
uuid
().
brand
('UserId'),
firstName
:
z
.
string
().
min
(1),
lastName
:
z
.
string
().
min
(1),
theme
:
z
.
enum
(['light', 'dark']),
language
:
z
.
enum
(['en', 'es', 'fr']),
subscriptionExpiresAt
:
z
.
date
().
optional
(),
billingAddress
:
z
.
string
().
optional
(),
}), {
getDisplayName
,
updateTheme
,
updateLanguage
,
isSubscribed
,
}, ); function
getDisplayName
(
user
:
User
) {
return `${
user
.
firstName
} ${
user
.
lastName
}`;
} function
updateTheme
(
user
:
User
,
theme
:
User
['theme']) {
return { ...
user
,
theme
};
} function
updateLanguage
(
user
:
User
,
language
:
User
['language']) {
return { ...
user
,
language
};
} function
isSubscribed
(
user
:
User
) {
return !!
user
.
subscriptionExpiresAt
;
}
typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
// Now each business domain has its own model export type
UserProfile
=
InferModelOutput
<typeof
UserProfile
>;
export const
UserProfile
=
createModel
(
z
.
object
({
userId
:
z
.
uuid
().
brand
('UserId'),
firstName
:
z
.
string
().
min
(1),
lastName
:
z
.
string
().
min
(1),
}), {
getDisplayName
,
}, ); export type
UserPreferences
=
InferModelOutput
<typeof
UserPreferences
>;
export const
UserPreferences
=
createModel
(
z
.
object
({
userId
:
z
.
uuid
().
brand
('UserId'),
theme
:
z
.
enum
(['light', 'dark']),
language
:
z
.
enum
(['en', 'es', 'fr']),
}), {
updateTheme
,
updateLanguage
,
}, ); export type
UserSubscription
=
InferModelOutput
<typeof
UserSubscription
>;
export const
UserSubscription
=
createModel
(
z
.
object
({
userId
:
z
.
uuid
().
brand
('UserId'),
subscriptionExpiresAt
:
z
.
date
().
optional
(),
billingAddress
:
z
.
string
().
optional
(),
}), {
isSubscribed
,
}, ); function
getDisplayName
(
user
:
UserProfile
) {
return `${
user
.
firstName
} ${
user
.
lastName
}`;
} function
updateTheme
(
user
:
UserPreferences
,
theme
:
UserPreferences
['theme']) {
return { ...
user
,
theme
};
} function
updateLanguage
(
user
:
UserPreferences
,
language
:
UserPreferences
['language'],
) { return { ...
user
,
language
};
} function
isSubscribed
(
user
:
UserSubscription
) {
return !!
user
.
subscriptionExpiresAt
;
}

3. Look for cohesion patterns ​

Properties that are always used together or changed together are good candidates for the same model. Properties that are used independently suggest separate models.

typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
export type
ProductId
=
InferModelOutput
<typeof
ProductId
>;
export const
ProductId
=
createModel
(
z
.
uuid
().
brand
('ProductId'));
export type
Product
=
InferModelOutput
<typeof
Product
>;
export const
Product
=
createModel
(
z
.
object
({
id
:
ProductId
.
schema
,
name
:
z
.
string
().
min
(1),
price
:
z
.
number
().
positive
(),
stock
:
z
.
number
().
int
().
min
(0),
reservedStock
:
z
.
number
().
int
().
min
(0),
viewCount
:
z
.
number
().
int
().
min
(0),
lastViewedAt
:
z
.
date
().
optional
(),
}), {
getDisplayPrice
,
getAvailableStock
,
trackView
,
}, ); // Uses only one property function
getDisplayPrice
(
product
:
Product
) {
return `$${
product
.
price
.
toFixed
(2)}`;
} // stock and reservedStock are used together function
getAvailableStock
(
product
:
Product
) {
return
product
.
stock
-
product
.
reservedStock
;
} // viewCount and lastViewedAt change together function
trackView
(
product
:
Product
) {
return { ...
product
,
viewCount
:
product
.
viewCount
+ 1,
lastViewedAt
: new
Date
(),
}; }
typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
export type
ProductId
=
InferModelOutput
<typeof
ProductId
>;
export const
ProductId
=
createModel
(
z
.
uuid
().
brand
('ProductId'));
// Can be reused out of the product context // e.g. to represent shipping cost export type
Price
=
InferModelOutput
<typeof
Price
>;
export const
Price
=
createModel
(
z
.
number
().
positive
().
brand
('Price'), {
toDisplayFormat
,
}); export type
Product
=
InferModelOutput
<typeof
Product
>;
export const
Product
=
createModel
(
z
.
object
({
id
:
ProductId
.
schema
,
name
:
z
.
string
().
min
(1),
price
:
Price
.
schema
,
}), ); export type
ProductInventory
=
InferModelOutput
<typeof
ProductInventory
>;
export const
ProductInventory
=
createModel
(
z
.
object
({
productId
:
ProductId
.
schema
,
stock
:
z
.
number
().
int
().
min
(0),
reservedStock
:
z
.
number
().
int
().
min
(0),
}), {
getAvailableStock
,
}, ); export type
ProductAnalytics
=
InferModelOutput
<typeof
ProductAnalytics
>;
export const
ProductAnalytics
=
createModel
(
z
.
object
({
productId
:
ProductId
.
schema
,
viewCount
:
z
.
number
().
int
().
min
(0),
lastViewedAt
:
z
.
date
().
optional
(),
}), {
trackView
,
}, ); function
toDisplayFormat
(
price
:
Price
) {
return `$${
price
.
toFixed
(2)}`;
} function
getAvailableStock
(
product
:
ProductInventory
) {
return
product
.
stock
-
product
.
reservedStock
;
} function
trackView
(
product
:
ProductAnalytics
) {
return { ...
product
,
viewCount
:
product
.
viewCount
+ 1,
lastViewedAt
: new
Date
(),
}; }

πŸ’‘ Prefer smaller models

When in doubt, prefer dividing models into smaller ones over keeping them together. It's always easier to merge models again if needed than to untangle a complex model.

When to Use Branded Types? ​

Branded types are a powerful pattern allowing you to create distinct types with consistent validation rules and related methods. However, they may be easily overused, causing your code to become bloated with type validations and the same methods repeated across different branded types.

To avoid this, here are some guidelines on when branded types are useful and when they are usually not.

βœ… Type usage requires its validation

When all utilities related to a type require it to be valid (match certain criteria), make it a branded type.

typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
export type
Email
=
InferModelOutput
<typeof
Email
>;
export const
Email
=
createModel
(
z
.
email
().
brand
('Email'));
// Those usages will break if email is not validated declare function
sendWelcomeEmail
(
to
:
Email
): void;
declare function
joinNewsletter
(
to
:
Email
): void;
declare function
createAccount
(
email
:
Email
): void;
declare function
validateDomain
(
to
:
Email
,
domain
: string): boolean;

❌ Type can be used the same way - branded or not

When type usage doesn't require any validation beyond TypeScript type matching, there is probably no need to brand it.

typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
export type
FullName
=
InferModelOutput
<typeof
FullName
>;
export const
FullName
=
createModel
(
z
.
string
().
brand
('FullName'));
// Those functions will work the same way for any string declare function
getInitials
(
fullName
:
FullName
): string;
declare function
createSlug
(
fullName
:
FullName
): string;
declare function
truncate
(
fullName
:
FullName
): string;

βœ… Type distinction provides additional value

When distinction between similar types benefits your code's type safety, brand each of them.

typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
export type
UserId
=
InferModelOutput
<typeof
UserId
>;
export const
UserId
=
createModel
(
z
.
uuid
().
brand
('UserId'));
export type
ProductId
=
InferModelOutput
<typeof
ProductId
>;
export const
ProductId
=
createModel
(
z
.
uuid
().
brand
('ProductId'));
export type
OrderId
=
InferModelOutput
<typeof
OrderId
>;
export const
OrderId
=
createModel
(
z
.
uuid
().
brand
('OrderId'));
// All ids have the same validation rules but distinguishing them // ensures we don't pass the wrong one (e.g. userId where productId expected) declare function
createOrder
(
userId
:
UserId
,
productId
:
ProductId
):
OrderId
;
declare function
addProduct
(
orderId
:
OrderId
,
productId
:
ProductId
): Order;
declare function
getUserOrders
(
userId
:
UserId
): Order[];

❌ Type distinction is purely cosmetic

When branding serves only a cosmetic purpose, it likely can be avoided.

typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
export type
PostTitle
=
InferModelOutput
<typeof
PostTitle
>;
export const
PostTitle
=
createModel
(
z
.
string
().
brand
('PostTitle'));
export type
PostContent
=
InferModelOutput
<typeof
PostContent
>;
export const
PostContent
=
createModel
(
z
.
string
().
brand
('PostContent'));
export type
PostSummary
=
InferModelOutput
<typeof
PostSummary
>;
export const
PostSummary
=
createModel
(
z
.
string
().
brand
('PostSummary'));
// All types can be used interchangeably, so branding them // only adds unnecessary complexity to function signatures declare function
escapeHtml
(
content
:
PostTitle
|
PostContent
|
PostSummary
,
): string; declare function
truncate
(
content
:
PostTitle
|
PostContent
|
PostSummary
,
length
: number,
): string; declare function
countWords
(
content
:
PostTitle
|
PostContent
|
PostSummary
,
): string;

βœ… Object properties are correlated

When in order for an object to be valid, its properties must maintain some correlations with each other, it's a sign you should brand this object.

typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
// Valid DateRange requires start date to be before end date export type
DateRange
=
InferModelOutput
<typeof
DateRange
>;
export const
DateRange
=
createModel
(
z
.
object
({
start
:
z
.
date
(),
end
:
z
.
date
(),
}) .
refine
((
data
) =>
data
.
start
<=
data
.
end
, {
message
: 'Start date must be before end date',
}) .
brand
('DateRange'),
{
isInRange
,
}, ); function
isInRange
(
date
: Date,
range
:
DateRange
) {
return
date
>=
range
.
start
&&
date
<=
range
.
end
;
}

❌ Object properties are independent

When each property validation depends only on its own value, the object can stay unbranded.

typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
z
} from 'zod';
export const
CommentId
=
createModel
(
z
.
uuid
().
brand
('CommentId'));
export type
CommentId
=
InferModelOutput
<typeof
CommentId
>;
// Each property is validated independently export type
Comment
=
InferModelOutput
<typeof
Comment
>;
export const
Comment
=
createModel
(
z
.
object
({
id
:
CommentId
.
schema
,
authorId
:
UserId
.
schema
,
postId
:
PostId
.
schema
,
content
:
z
.
string
().
min
(1),
}), );