Rich Domain Models with TypeScript
Cory Rylan
- 6 minutes
Domain Driven Design is a broad topic in software development with many varying opinions. One of the ideas behind Domain Driven Design is the separation of business logic (domain) from the rest of the application or implementation details. In this post, we are going to show how you can create rich domain models or special classes that represent business rules and relationships. This post will focus on building domain models for client-side JavaScript applications like Angular, React or VueJS.
With domain models, we want to represent some business logic and be able to enforce that in code. Domain Driven Design would encourage business logic to be in a single isolated place for parts of our application to plug into. If you want to learn more in-depth about domain driven design I recommend these two books, Domain-Driven Design: Tackling Complexity in the Heart of Software and Implementing Domain-Driven Design.
Building domain models can be done in pure JavaScript classes. In this post we will use TypeScript as it will provide some beneficial type information to our models.
For client-side JavaScript applications, we still have to duplicate some of the server side business logic sometimes for specific use cases. For example form validation we need to enforce specific business rules on the client. A user account creation may need to check that a user has a required name and minimum age to create an account. For our use case, we will create a user domain model to represent a user object in our system.
export class User {
private _firstName: string;
private _lastName: string;
private _age: number;
get firstName() {
return this._firstName;
}
get lastName() {
return this._lastName;
}
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
get age() {
return this._age;
}
constructor(firstName: string, lastName: string, age: number) {
this.setName(firstName, lastName);
this.setAge(age);
}
setName(firstName: string, lastName: string) {
if (this.validName(firstName) && this.validName(lastName)) {
this._firstName = firstName;
this._lastName = lastName;
}
}
setAge(age: number) {
if (age >= 18) {
this._age = age;
} else {
throw new Error('User age must be greater than 18');
}
}
private validName(name: string) {
if (name.length > 0 && /^[a-zA-Z]+$/.test(name)) {
return true;
} else {
throw new Error('Invalid name format');
}
}
}
const user = new User('John', 'Doe', 18);
user.setName('Jack', 'Doe');
console.log(user.fullName); // Jack Doe
const user2 = new User('John', 'Smith', 17);
// Error: User age must be greater than 18
With our User domain model, we define the properties with public getters and setter methods. By using setter methods instead of standard setters, we can pass in multiple parameters easily and call the logic internally in our class. With our User class, we can enforce some business rules. One rule is a user must be at least 18 years or older. If the user is created with a younger age, the class throws an exception. Our user must also have a valid first and last name.
The issue we run into commonly when dealing with rich domain classes is how should we create an instance with existing data? Many client-side applications load JSON data from an API. We will need to take that data to create our domain models with that data. We could instantiate the class every time we get data, but sometimes we may have existing data that needs to be in the class but can't be easily passed into the constructor. JavaScript classes can have only one constructor. Just having a single constructor makes it challenging to create class instances with various pieces of data dependent on what we are loading.
We can use some JavaScript tricks to be able to create an instance of a User from existing an existing JSON object without having to invoke the constructor. To do this, we need to update our model class. First, let's take a look at our JSON data we are getting from our API.
export interface IUser {
firstName?: string;
lastName?: string;
age?: number;
weight?: number;
}
const userDto: IUser = {
// DTO (data transfer object)
firstName: 'Cory',
lastName: 'Rylan',
age: 100,
weight: 150
};
Our API returns a User object we call our DTO (data transfer object) with the four properties above. We have a TypeScript Interface to describe this data shape. We want to take this data and create an instance of our User without having to pass everything into the constructor. To accomplish this, we can use a hydration strategy. For our User model to support a hydration strategy, we have to update its internal implementation.
export interface IUser {
firstName?: string;
lastName?: string;
age?: number;
weight?: number;
}
export class User {
private state: IUser = {};
get firstName() {
return this.state.firstName;
}
get lastName() {
return this.state.lastName;
}
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
get age() {
return this.state.age;
}
constructor(firstName: string, lastName: string, age: number) {
this.setName(firstName, lastName);
this.setAge(age);
}
setName(firstName: string, lastName: string) {
if (this.validName(firstName) && this.validName(lastName)) {
this.state.firstName = firstName;
this.state.lastName = lastName;
}
}
setAge(age: number) {
if (age >= 18) {
this.state.age = age;
} else {
throw new Error('User age must be greater than 18');
}
}
private validName(name: string) {
if (name.length > 0 && /^[a-zA-Z]+$/.test(name)) {
return true;
} else {
throw new Error('Invalid name format');
}
}
}
We move the internal private properties to be on a single private internal state
property. By moving the internal private properties onto a single
object, we can use the state
property to easily copy or hydrate the class with the data from our API. Let's look at the hydration logic that will copy our data over.
export interface Type<T> extends Function {
new (...args: any[]): T;
}
export class DomainConverter {
static fromDto<T>(domain: Type<T>, dto: any) {
const instance = Object.create(domain.prototype);
instance.state = dto;
return instance as T;
}
}
Our utility class DomainConverter
has a single method fromDto
that will take two parameters. First, we pass the Domain type we want to create, second, we pass the JSON object we got from our API. To create an instance of the User class without calling the constructor, we create a new object with the Domain class prototype and then assign the internal private state
to what we got from our API. Our with this implementation we get the following:
// would be from an API request
const userDto: IUser = {
firstName: 'Cory',
lastName: 'Rylan',
age: 27,
weight: 150
};
const user = DomainConverter.fromDto<User>(User, userDto);
console.log(user.fullName);
Now with our DomainConverter
we can efficiently hydrate instances of our domain classes. The next thing we will need is the ability to take our domain class and output the JSON object to send to our API. For example, we update our username on our user instance. We now want to submit the new user data to our API to update the backend. To do this, we need a plain Object to serialize to JSON. We can't send a rich domain model because it would have all the logic and methods attached.
We can add a new method to our DomainConverter
to convert from a domain instance back to a simple JSON object.
export interface Type<T> extends Function {
new (...args: any[]): T;
}
export class DomainConverter {
static fromDto<T>(domain: Type<T>, dto: any) {
const instance = Object.create(domain.prototype);
instance.state = dto;
return instance as T;
}
static toDto<T>(domain: any) {
return domain.state as T;
}
}
With the new toDto()
method we can pass in a domain class instance and return the state to use and send to our API to update.
const userDto: IUser = {
firstName: 'Cory',
lastName: 'Rylan',
age: 27,
weight: 150
};
const user: User = DomainConverter.fromDto<User>(User, userDto);
const userData: IUser = DomainConverter.toDto<User>(user);
console.log(userData);
// { firstName: "Cory", lastName: "Rylan", age: 27, weight: 150 }
Using an internal state object and our DomainConverter
we can easily create domain model instances from existing API data as well as convert it back to plain data objects to send to our API endpoints. Domain Models can be very useful for ever growing complex client-side JavaScript applications. A big thanks to Lance Finney for reviewing and providing improvements to the TypeScript example! Take a look at the working example in the demo link below.