Skip to main content

Evolution of data models from primitive to clean entities in TypeScript

Data is the raw material of our programs. We must pay attention to its definition, cohesion and correctness. Often we find information that is scattered, redundant, or missing. This leads us to dirty and error-prone developments.

Bad programmers care about the code. The good ones care about data structures and their relationships.

Linus Torvalds

Some solutions are unnecessarily complex. Other times they are applied out of place. I present to you a path of evolution so that little by little you can shape your business model. And most importantly, write maintainable programs in clean Typescript.

Data models evolution


🎒 Prerequisites

To complete this tutorial, you will need:


0️⃣ Primitive obsession

This scenario takes its name from the fact that we are using primitive types to represent our data. Very common at the beginning of a developer's career. But, as we will see, it is not the best solution.

Common problems are:

  • Redundant names
  • Lack of cohesion
  • lack of data validation (or pulled apart)
const clientName = "Mark Gates";
const clientCountry = "USA";
const clientCity = "Los Angeles"; // i am tired of writing client...
const isDeferredPayment = true; // is this still related to the client?
const amount = 999; // could it be negative?
const monthsDeferred = 0; // is it related to isDeferredPayment?
const isRecurredPayment = false; // could be true when isDeferredPayment is also true?
const cardNumber = "1234123412341234"; // is a string, so, could accept letters?
const cardValidUntil = "12/29"; // could accept 2026-6?
const cardVerificationCode = 123; // could accept short numbers?

1️⃣ Data Transfer Objects

The first and most common solution to repetitive naming and lack of cohesion is to use Data Transfer Objects. They are a way to group related fields.

In TypeScript, you can use a type or an interface to declare the shape of your DTOs. I prefer the latter because of my Java and C# background.

interface ClientDTO {
  name: string;
  country: string;
  city: string;
}

interface PaymentDTO {
  amount: number;
  isDeferredPayment: boolean;
  monthsDeferred: number;
  isRecurredPayment: boolean;
}

interface CardDTO {
  number: string;
  validUntil: string;
  verificationCode: number;
}

2️⃣ Value Objects

Here we have an evolution step. We are going to use Value Objects to validate and represent our data. The price to pay is that we will need to define classes and instantiate objects on them.

The main idea is to set validation rules close to defined data. This way we can avoid errors and improve the readability of our code. These rules are called invariants; conditions that must be met for the object to be valid.

We can write them in the constructor to ensure a clean start. Also, we can use readonly properties to prevent data from being modified, or implement a rule in a set method for mutable data.

class ClientVO {
  // inmutable data
  constructor(
    public readonly name: string,
    public readonly country: string,
    public readonly city: string
  ) {
    if (name.length < 3) {
      throw new Error("Name must be at least 3 characters");
    }
  }
}

class PaymentVO {
  // mutable, but with validation
  private _amount: number;
  public get amount(): number {
    return this._amount;
  }
  public set amount(value: number) {
    if (value < 0) {
      throw new Error("Amount must be greater than 0");
    }
    this._amount = value;
  }
  constructor(
    amount: number,
    public readonly isDeferredPayment: boolean,
    public readonly monthsDeferred: number,
    public readonly isRecurredPayment: boolean
  ) {
    this.amount = amount;
    if (isDeferredPayment && isRecurredPayment) {
      throw new Error("Payment can't be deferred and recurred");
    }
    if (isDeferredPayment && monthsDeferred < 1) {
      throw new Error("Months deferred must be greater than 0");
    }
  }
}

class CardVO {
  public readonly number: string;
  public readonly validUntil: string;
  public readonly verificationCode: number;
  constructor(number: string, validUntil: string, verificationCode: number) {
    // complex validations on their own methods
    this.number = this.getNumber(number);
    this.validUntil = this.getValidUntil(validUntil);
    this.verificationCode = this.getVerificationCode(verificationCode);
  }
  private getNumber(number: string) {
    number = number.replace(/\s/g, "");
    if (number.length !== 16 && number.match(/[^0-9]/)) {
      throw new Error("Card number must be 16 digits");
    }
    return number;
  }
  private getValidUntil(validUntil: string): string {
    validUntil = validUntil.replace(/\s/g, "");
    if (validUntil.length !== 5 && validUntil.match(/[^0-9/]/)) {
      throw new Error("Valid until must be 5 digits only digits and slash");
    }
    if (parseInt(validUntil.substring(0, 2)) > 12) {
      throw new Error("Month must be between 1 and 12");
    }
    return validUntil;
  }
  private getVerificationCode(verificationCode: number): number {
    if (verificationCode < 100 || verificationCode > 999) {
      throw new Error("Verification code must be between 100 and 999");
    }
    return verificationCode;
  }
  // change representation without changing the value
  getExpirationDate() {
    const monthOrdinal = parseInt(this.validUntil.substring(0, 2)) - 1;
    const year = parseInt(this.validUntil.substring(3, 5));
    return new Date(year, monthOrdinal, 1);
  }
  getMaskedNumber() {
    const last = this.cardData.number.substring(12);
    const maskedNumber = `**** **** **** ${last}`;
    return maskedNumber;
  }
}

3️⃣ Entities

Until now, we have store, validate, and represent our data. But, what if we need to add some behavior to them? We can use Entities to do that.

Here, the focus is on business logic. We can add methods to our classes to perform operations on the data, which is now encapsulated in a property.

class Client {
  constructor(public readonly clientData: ClientVO) {}
  // no behavior yet
}

class Card {
  constructor(public readonly cardData: CardVO) {}
  isExpired() {
    // impure logic dependent on context
    return this.cardData.getExpirationDate() < new Date();
  }
  checkCardLimit(amount: number) {
    // impure and potentially complex logic
    console.log(`get card limit online...`);
    const limit = 1000;
    if (amount > limit) {
      throw new Error(`Card ${this.cardData.number} limit exceeded`);
    }
    return true;
  }
}

class Payment {
  constructor(public readonly paymentData: PaymentVO) {}
  payWithCard(card: Card) {
    // could use other entities to perform the operation
    const cardMasked = card.cardData.getMaskedNumber();
    if (card.isExpired()) {
      throw new Error(`Card ${cardMasked} is expired`);
    }
    card.checkCardLimit(this.paymentData.amount);
    console.log(`Charged ${this.paymentData.amount} on card ${cardMasked}`);
  }
}

4️⃣ Aggregates

Sometimes, we need to group entities to perform operations on them. In other cases, we want to ensure relations and cardinality between entities. Either way, we can use Aggregates to do that.

class ClientAggregate {
  public readonly cards: Card[] = [];
  // ensures that the client has always a preferred card
  constructor(public readonly client: Client, private preferredCard: Card) {}
  addCard(card: Card, isPreferred: boolean) {
    this.cards.push(card);
    if (isPreferred) {
      this.preferredCard = card;
    }
  }
  getPreferredCard() {
    return this.preferredCard;
  }
}

class ClientPaymentsAggregate {
  // stores payments related to a client
  private payments: PaymentVO[] = [];
  // we can aggregate an entity or another aggregate
  constructor(public readonly client: ClientAggregate) {}
  performPayment(payment: Payment) {
    const card = this.client.getPreferredCard();
    payment.payWithCard(card);
    this.payments.push(payment.paymentData);
  }
  getPayments() {
    return [...this.payments];
  }
}

5️⃣ Domain

The Domain is the bread and butter of our application. The problem to solve. The reason why we are paid.

Every application has a Domain. Good architected applications have their domain explicitly declared and apart. Encapsulating the kind of things that are independent of the framework, database, or any other external detail.

Going deeper into this topic is beyond the intention of this post. You can know more about Domain and Domain Driven Design here.


🌅 Conclusion

We have seen how to use Value Objects, Entities, and Aggregates to encapsulate our data and add behavior to it. We also know what Domain is. Time to evolve and place our code above primitive solutions.

To learn more you can read my post about data modeling with anemic and rich models.

In any case, I hope this post can help you write better and clean TypeScript code.

learn, code, enjoy, repeat

Alberto Basalo

Popular posts from this blog

Fine-tune ESLint rules to write better TypeScript

Writing clean code is a lot easier with the right tools well configured. ESLint is a static analyzer for JavaScript programs. So what does that mean and what can it do for my TypeScript code? First things first, by static it means that you don't need to run or even compile a program to parse it. Analyzing means that it checks your code searching for flaws or bad metrics that can cause problems in the long run. The ESLint is a linter that runs before the compiler. Witch runs before your automated tests and the end-user. Is your first line of defense and should be as integrated with your editor as possible. For the details about integrating ESLint and VSCode, you can read my article to configure basic extensions But ESLint would not be so popular if it was not extensible and configurable . And there is the real power and complexity. In this article, I'll walk you through

10 commandments to naming and writing clean code with TypeScript

A code writer is like any other writer; writes to be read and understood . To do so it must obey certain laws. They are so important that must be followed religiously. Being a good writer is an art. But you can be a better programmer by knowing and applying a set of standards. In this guide, I will show you TypeScript samples for the  10 rules of clean naming . When you're finished, you'll be able to write heavenly code.Let there be code naming conventions 1️⃣ Be Descriptive Add value without being repetitive. Context is key. Prefer clarity over saving space. We are not in the ‘90s’ anymore. // ❌ const width = 5 ; class Image { imageWidth = width ; } // ✅ const imageWidth = 5 ; class Image { width = imageWidth; } 2️⃣ Be Meaningful Use the same word for the same concept. Create a dictionary for business and infrastructure common words. // ❌ getClient () {} readProvider () {} postCustomer () {} // ✅ getClient ()

How to configure VSCode to code better TypeScript

Writing a more readable code is your first goal as a programmer. This initial Visual Studio Code setup makes your job easier. Any good worker must know and customize his tools for the job at hand. With minor tweaks, VSCode is the perfect tool to write TypeScript . There are plenty of guides to configure VSCode . Most of them include a lot of extensions. But almost none starts from the very beginning, with no extension at all. I am an enthusiast of writing clean code , so I do my best to promote and facilitate this goal, and I am sure that this can help you to write better code. In this post, you will learn how to adapt VS Code to write better TypeScript code, even without any extension. And above all, you will get tips to adopt good habits and detect programming vices. 🎒 Prerequisites To complete this tutorial, you will need: A local copy of Visual Studi