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
VideoTitle
=
InferModelOutput
<typeof
VideoTitle
>;
export const
VideoTitle
=
createModel
(
z
.
string
().
brand
('VideoTitle'));
// Those functions will work the same way for any string declare function
createSlug
(
videoTitle
:
VideoTitle
): string;
declare function
truncate
(
videoTitle
:
VideoTitle
,
maxLength
: number): string;
export type
SongTitle
=
InferModelOutput
<typeof
SongTitle
>;
export const
SongTitle
=
createModel
(
z
.
string
().
brand
('SongTitle'));
// Due to type branding, we need do duplicate all functions declare function
createSlug
(
songTitle
:
SongTitle
): string;
declare function
truncate
(
songTitle
:
SongTitle
,
maxLength
: number): 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),
}), );

Branded Types Composition ​

As a branded type is just an intersection of the base type and the brand, it can be passed to functions that expect either the full branded type or just the base type.

typescript
import { 
Email
} from './email';
declare function
sendEmail
(
email
:
Email
): void;
declare function
track
(
message
: string): void;
// Hover to see the type const
email
=
Email
.
from
('[email protected]');
// Matches the Email branded type
sendEmail
(
email
);
// Matches the string base type
track
(
email
);
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'));

However, there is nothing stopping us from adding more intersections (brands) to a type in order to further constrain it for specific use cases while making it compatible with the existing ones.

typescript

import { 
AuthEmail
} from './auth-email';
import {
Email
} from './email';
declare function
signIn
(
email
:
AuthEmail
): void;
declare function
sendEmail
(
email
:
Email
): void;
declare function
track
(
message
: string): void;
// Hover to see the types const
email
=
Email
.
from
('[email protected]');
const
authEmail
=
AuthEmail
.
from
('[email protected]');
// Both can be tracked as they are strings
track
(
authEmail
);
track
(
email
);
// Both can be sent as they are emails
sendEmail
(
authEmail
);
sendEmail
(
email
);
// Only AuthEmail can be used to sign in
signIn
(
authEmail
);
signIn
(email);
Argument of type 'string & $brand<"Email">' is not assignable to parameter of type 'string & $brand<"Email"> & $brand<"AuthEmail">'. Type 'string & $brand<"Email">' is not assignable to type '$brand<"AuthEmail">'. Types of property '[$brand]' are incompatible. Property 'AuthEmail' is missing in type '{ Email: true; }' but required in type '{ AuthEmail: true; }'.
typescript
import { 
createModel
, type
InferModelOutput
} from '@uni-ts/model';
import {
Email
} from './email';
export type
AuthEmail
=
InferModelOutput
<typeof
AuthEmail
>;
export const
AuthEmail
=
createModel
(
Email
.
schema
.
brand
('AuthEmail'));
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'));

While composing branded types, keep in mind rules from the previous section to ensure they actually make your project more maintainable instead of just adding additional complexity.

πŸ’‘ Validation + Domain

The common use case for composing branded types is when the first branded type is validation-related and the following ones are domain-related. A great example is the one above where we use Email to validate if the string is a valid email address and then use AuthEmail to further constrain it for authentication use cases.