Regarding my last article about classes, interfaces, and rich model: Classes over interfaces in TypeScript, I would like to introduce how you can handle application entities. If you have some logic instead of CRUD in your application, you’re bound to find my advice useful
Entities as a reflection of the database
A common practice focuses on data instead of logic and concentrates more on the database-related aspects like tables, columns, and relations. This approach provides that all our domain models are entities with only setters and getters. However, focusing only on database modeling is flawed. It would be more useful if you concentrated on your domain to create behavior and moved the business terminology to your code.
First of all, the database is just an implementation detail. You can choose the right one later if you only use the abstraction layer on the data access layer.
What is a domain? 💡
It’s a problem to solve in our application. For example, if you create a transport booking system, a problem could be delivering people from place A to B. The Domain also needs to have its responsibilities and own ubiquitous language. The ubiquitous language is equivalent to business language. If we think about a customer who places an order, we’ll name the method "customer.placeOrder" instead of "customer.addOrder".
However, what does it mean that it’s an entity? When the entity is recognizable in our system and has a uniqueness to it, individual behavior may also be different than what we would observe at the start. The entity is distinguished by the fact that we want to keep track of changes in it. A close analogy is that of a company. The company is unique because it has an individual tax number but can also change its business location if needed.
Of course, if we have to add some meta fields to our table, we can do this using the right tools and techiques like object-relational mapping (ORM). Behind the scenes, we could create extra fields inside our database schema:
import { DataSource, EntitySchema } from 'typeorm';
// correct model
export class CorrectCustomer {
readonly id: string;
}
const correctCustomEntityModel = new EntitySchema<CorrectCustomer & Record<any, any>>({
name: CorrectCustomer.name,
target: CorrectCustomer,
columns: {
id: {
type: 'uuid',
primary: true,
generated: 'uuid',
},
createdAt: {
type: 'datetime',
createDate: true,
},
},
});
// incorrect model
export class IncorrectCustomer {
readonly id: string;
readonly createdAt: Date;
}
const incorrectCustomEntityModel = new EntitySchema<IncorrectCustomer>>({
name: CorrectCustomer.name,
target: CorrectCustomer,
columns: {
id: {
type: 'uuid',
primary: true,
generated: 'uuid',
},
createdAt: {
type: 'datetime',
createDate: true,
},
},
});
export const dataSource = new DataSource({
type: 'better-sqlite3',
database: 'db.db',
synchronize: true,
entities: [correctCustomEntityModel, incorrectCustomEntityModel],
});
As we can see, we have a correct and incorrect model. Both are as simple as possible but we can notice one difference between them. In the correct model, we don’t have the “createdAt” field because we care about the customer's behavior, and this field does not create any behavior. The “createdAt” field stores the date when this particular record is created. Notice how I’ve hidden the “createdField” in the CorrectCustomer entity but you can see this field inside the “EntitySchema”.
Identity
In life, we can find several easy examples of proof of identity, such as tax numbers. Each company must have its tax identification number, and the identity is permanent throughout the company's life cycle. As in the example with the company, the entity has to have a permanent ID. However, in some cases, we don’t need an ID. In general, the ID is needed for database purposes, and we can create an abstraction over the ID for our database. We can implement the ID only in ORM. Let's consider the above example with a company and its address.
In our application, we actually don’t need an ID of address because we’ll change the address of our company through the company entity, and the ID of the address is good enough to keep only in database side. Let’s me show you an example:
import { DataSource, EntitySchema } from 'typeorm';
// It isn’t an entity. I elaborate more on that in the life cycle paragraph
class Address {
street: string;
city: string;
postcode: string;
constructor(partial?: Partial<Address>) {
Object.assign(this, partial);
}
}
type AddressDto = Record<keyof Omit<Address, 'id'>, string>;
class Company {
readonly id: string;
address: Address;
changeAddress(newAddress: AddressDto) {
this.address = new Address({
street: newAddress.street,
city: newAddress.city,
postcode: newAddress.postcode,
});
}
}
const addressEntitySchema = new EntitySchema<Address & { id: string }>({
name: Address.name,
target: Address,
columns: {
id: {
type: 'uuid',
primary: true,
generated: 'uuid',
},
street: {
type: 'varchar',
},
},
});
const companyEntitySchema = new EntitySchema<Company>({
name: Company.name,
target: Company,
columns: {
id: {
type: 'uuid',
primary: true,
generated: 'uuid',
},
},
relations: {
address: {
type: 'one-to-one',
target: Address.name,
},
},
});
export const dataSource = new DataSource({
type: 'better-sqlite3',
database: 'db.db',
synchronize: true,
entities: [companyEntitySchema, addressEntitySchema],
});
As you can see, the entity’s identity is one but not the least important thing to recognize your entity. Sometimes, we think that we need an ID to our model but most of cases we don’t need it. It will allow us to think more about domain behavior rather than database models. I’ll provide you with several general approaches to generating a unique ID: auto-increment, UUID, or hybrid.
Some approaches to generating a unique IDTermDescriptionAdvantagesDrawbacksAuto-increment keys It’s good when we need the ID to be readable for humans. However, applications based on auto-increment keys will be harder to scale because each time we create a resource, we will need to wait for the database to get created.
- Readable for humans,
- Can be used to develop high-performance pagination,
- Naturally sortable,
- It takes less space (8 bytes),
- Less secure than a random ID, someone can guess the subsequent numbers of the resources,
- We need to save the resource to know the resource ID,
- It’s hard to implement in some databases,
- High risk of ID collision
UUID (Universal Unique Identifier)
It’s indispensable when you have to implement some architecture pattern like CQRS (Command Query Responsibility Segregation). It is ready to work in almost any standard programming language library.
- Hard to guess (security-friendly),
- Can be generated on the fly,
- Globally unique,
- Database can take care of input (e.g., Postgres has the UUID data type),
- Can be generated in every layer of our application (front-end, back-end, database).
- Less friendly for humans,
- Not naturally sortable,
- In some databases, the engines will hurt insertion performance,
- It takes more spaces (16 bytes)
- It’s more difficult to create a high-performance pagination.
Hybrid It’s good when we need something between a readable and random ID.For example, we can take the current date and last part of UUID and concat them. We’ll get something like this: 2022-08-31-366ac2ba480f.
- Can be reading- friendly,
- Can be sorting-friendly,
- More secure than auto-increment,
- Harder to guess compared to auto-increment.
- It may appear duplicate,
- Database engine can’t take care of the input,
- We need to create an internal ID generator.
Of course, we can find other ID strategies like email for user entity, but it will have some consequences. If the user changes their email address, we will have to update each field where this email address occurs. In some architectures (CQRS, long-process tasks), we need a random ID because we would like to return this ID and process new resources in the background. So, we would like to know the status of this resource, but without this ID, we are not able to check the status.
Behavior
To create a rich model, we first need to explore the entity's behavior. We must communicate with our business, and the business will tell us which words they use in “real life.” Let’s take a look at an example of an e-commerce application — something many developers have an experience with.
export class Order {
static create() {
return new Order({
id: randomUUID(),
});
}
readonly id: string;
constructor(partial: Partial<Order>) {
Object.assign(this, partial);
}
}
// correct model
export class CorrectCustomer {
readonly id: string;
private _orders: Order[] = [];
placeOrder() {
this._orders.push(Order.create());
// propagte event: OrderPlaced
}
}
// incorrect model
export class IncorrectCustomer {
readonly id: string;
readonly createdAt: Date;
private _orders: Order[] = [];
setOrder() {
this._orders.push(Order.create());
}
}
The incorrect model contains the "setOrder" method, but why set order? Actually, the customer places an order! If you think about more lifelike behavior, you will exclude setters and getters in your entities. As a side effect, you will have an entity of methods with business scenario names. Your next step to code improvement would be tracking the changes by emitting an event every time the entity is changed.
Life cycle
The life cycle is crucial to recognize our entity. We tend to think each database table is an entity, but it’s not true! Sometimes in tables, we have values instead of entities but these values do not change in time. For example, we have two classes: “client” and “address” together with a functionality that allows the client to change the address. Let’s think about the address for a while. Is this really an entity? An address cannot change in time but the client can move to another place at a different address, while the old one stays in the same place. What can we do with this case in our code? The easiest way is to replace the old data with the new!
import { CustomerRepository } from '../repositories/customer-repository';
import { randomUUID } from 'crypto';
class Address {
street: string;
city: string;
postcode: string;
constructor(partial?: Partial<Address>) {
Object.assign(this, partial);
}
}
type AddressDto = Record<keyof Omit<Address, 'id'>, string>;
class IncorrectCustomer {
readonly id: string;
address: Address;
}
class CorrectCustomer {
readonly id: string;
private _address: Address;
changeAddress(newAddress: AddressDto) {
this._address = new Address({
street: newAddress.street,
city: newAddress.city,
postcode: newAddress.postcode,
});
}
}
export class CustomerService {
constructor(private readonly _customerRepository: CustomerRepository) {}
async incorrectChangeAddress(newAddress: AddressDto) {
const customer = await this._customerRepository.findOne<IncorrectCustomer>();
customer.address.street = newAddress.street;
customer.address.city = newAddress.city;
customer.address.postcode = newAddress.city;
await this._customerRepository.save(customer);
}
async correctChangeAddress(newAddress: AddressDto) {
const customer = await this._customerRepository.findOne<CorrectCustomer>();
customer.changeAddress(newAddress);
await this._customerRepository.save(customer);
}
}
Notice how in the incorrect example, I’ve updated the address from the customer and assigned new values to the existing one. It isn’t good practice to mutate an object if we can just replace it with a new instance. The mutation of objects may lead to inconsistent state in our application! I’ll show you we can change one object's properties without mutating it:
class Address {
street: string;
city: string;
postcode: string;
constructor(partial?: Partial<Address>) {
Object.assign(this, partial);
}
changeStreet({ street }: { street: string }) {
return new Address({
city: this.city,
postcode: this.postcode,
street: street,
});
}
}
The result is that I used existing instance of the Address class and I created a new one with new street passed by a method argument. In that simple way, you can avoid the object’s mutation.
Also, I have to mention the Address class is not an entity because this class cannot mutate and evolve in time. The only thing we can do with it is just replace it. Actually, the Address is a value. It means that it stores only value like money. We can’t change the value of the existing banknote, but we can replace it with others. As a result, we have to be careful not to make mistakes when dealing with entities.
Do you feel like reading about a TypeScript-based project? Check out the Biocore case study!
Keep calm and separate your entities from the data model
The recognition of entities and separation from the data model is crucial to create a maintainable application. When you design an application, first of all, you should focus on how to solve business problems instead of how to design a data model and tables, so that your code is cleaner and independent from the database. The database is just an implementation detail. You can choose the right one, once you know all your database needs. Also, to recognize your entities, you should think about these three things: identity, behavior, and life cycle. If you have them, you are sure that your model is really an entity.
Is Node.js your kind of thing? Check out the backend recruitment opportunities at Merixstudio and let’s join forces!
Navigate the changing IT landscape
Some highlighted content that we want to draw attention to to link to our other resources. It usually contains a link .