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
withcanWithdraw
may be a domain model in a banking app whileSerializer
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 onTodoItem
) and pure utility functions (e.g. from libraries likelodash
).
Below you can see some examples of domain (β ) and non-domain (β) models for an e-commerce app.
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;
}
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;
}
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';
}
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,
}),
);
}
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.
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;
}
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.
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;
}
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.
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(),
};
}
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.
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.
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.
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.
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.
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.
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),
}),
);