Introducing Unified Modeling Language (UML)
You now know how to work with VSCode and have a firm understanding of its code base and some examples. We will complete this chapter by learning about UML and how we can utilize it to study design patterns. We will focus on a limited set of UML, specifically class diagrams, since this is the traditional way to depict design patterns; plus, they are straightforward to comprehend.
What is UML?
UML is a standardized way of modeling software architecture concepts, as well as interactions between systems or deployment configurations. Nowadays, UML covers more areas, and it's fairly comprehensive. It came as a result of a consolidation of similar tools and modeling techniques, such as use cases, the Object Modeling Technique (OMT), and the Booch Method.
You don't really need to know all the ins and outs of UML, but it is really helpful when you're learning about design patterns. When you first learn about design patterns, you want to have a holistic overview of the patterns, irrespective of the implementation part, which will differ from language to language. Using UML class diagrams is a perfect choice for modeling our patterns in a design language that everyone can understand with minimal training.
Let's delve into more practical examples using TypeScript.
Note
Although UML diagrams have a long history in software engineering, you should use them carefully. Generally, they should only be used to demonstrate a specific use case or sub-system, together with a short explanation of the architecture decisions. UML is not very suitable for capturing the dynamic requirements of very complex systems because, as a visual language, it is only suitable for representing high-level overviews.
Learning UML class diagrams
UML class diagrams consist of static representations of the classes or objects of a system. TypeScript supports classes and interfaces, as well as visibility modifiers (public
, protected
, or private
) so that we can leverage those types to describe them with class diagrams. Here are some of the most fundamental concepts when studying class diagrams:
- A class represents a collection of objects with a specific structure and features. For example, the following
Product
class looks like this:class Product {}
This corresponds to the following diagram:
- An interface is usually attached to a class and represents a contract that the class adheres to. This means that the class implements this interface:
interface Identifiable<T extends string | number>{ id: T } class Product implements Identifiable<string> { id: string constructor(id: string) { this.id = id; } }
This corresponds to the following diagram. Notice the placement of the interface clause on top of the class name within the left shift (
<<
) and right shift (>>
) symbols:
- An abstract class represents an object that can't be directly instantiated:
abstract class BaseApiClient {}
This corresponds to the following diagram. The name of the class is in italics:
- An association represents a basic relationship between classes, interfaces, or similar types. We use associations to show how they are linked with each other, and this can be direct or indirect. For example, we have the following models for
Blog
andAuthor
:class Blog implements Identifiable<string> { id: string; authorId: string; constructor(id: string, authorId: string) { this.id = id; this.authorId = authorId; } } class Author {}
This corresponds to the following diagram.
Blog
is connected toAuthor
with a line:
Notice that because the Author
class here is not being passed as a parameter, it is referenced from the authorId
parameter instead. This is an example of indirect association.
- An aggregation is a special case of association when we have two entities that can exist when one is missing or not available. For example, let's say we have a
SearchService
that accepts aQueryBuilder
parameter and performs API requests on a different system:class QueryBuilder {} class EmptyQueryBuilder extends QueryBuilder {} interface SearchParams { qb?: QueryBuilder; path: string; } class SearchService { queryBuilder?: QueryBuilder; path: string; constructor({ qb = EmptyQueryBuilder, path }: SearchParams) { this.queryBuilder = qb; this.path = path; } }
This corresponds to the following diagram.
SearchService
is connected toQueryBuilder
with a line and a white rhombus:
In this case, when we don't have a QueryBuilder
or the class itself has no queries to perform, then SearchService
will still exist, although it will not actually perform any requests. QueryBuilder
can also exist without SearchService
.
Composition is a stricter version of aggregation, where we have a parent component or class that will control the lifetime of its children. If the parent is removed from the system, then all the children will be removed as well. Here is an example with Directory
and File
:
class Directory { files: File[]; directories: Directory[]; constructor(files: File[], directories: Directory[]) { this.files = files; this.directories = directories; } addFile(file: File): void { this.files.push(file); } addDir(directory: Directory): void { this.directories.push(directory); } }
This corresponds to the following diagram. Directory
is connected to File
with a line and a black or filled rhombus:
- Inheritance represents a parent-child relationship when there is one or more sub-classes that inherit from base classes (also known as a superclass):
class BaseClient {} class UsersApiClient extends BaseClient {}
This corresponds to the following diagram.
UsersApiClient
is connected toBaseClient
with a line and a white pointed arrow:
- Visibility is related to attributes that the class contains and how they are accessed. For example, we have an
SSHUser
class that accepts a private key and a public key:class SSHUser { private privateKey: string; public publicKey: string; constructor(prvKey: string, pubKey: string) { this.privateKey = prvKey; this.publicKey = pubKey; } public getBase64(): string { return Buffer.from(this.publicKey).toString ("base64"); } }
This corresponds to the following diagram.
SSHUser
contains two properties and one method. We use a minus (-
) for private visibility and a plus (+
) for public visibility:
Here, we can see that the methods are separated by a horizontal bar for visibility.
We can also add notes or comments to class diagrams, although it's not very clear if they should be included in the code:
The main difficulty when using class diagrams is not drawing them on a piece of paper, but rather how to properly model the domain classes and relationships in a sound manner. This process is often iterative and involves interacting with several domain experts or knowledgeable stakeholders. In Chapter 8, Developing Modern and Robust TypeScript Applications, we are going to learn how domain-driven design can help us with modeling business rules.