Domain-driven design (DDD) is a software development approach that aims to simplify the creation of applications that involve complex business logic. In this article, we’ll explore how to leverage TypeScript for DDD. TypeScript’s sophisticated type system enables fine-grained domain modeling and is highly adaptable, lending itself to complex app development.
We’ll dive into the main principles and guidelines of domain-driven design, discuss how TypeScript can assist with DDD, and investigate if DDD can benefit frontend programming. We’ll also take a look at a domain-driven design example written in TypeScript.
In software engineering, a domain is the specific area of knowledge used by the computer program. In other words, the domain of software is the subject area where the software applies.
For example, if we develop an infotainment application for a car model, the domain will be automotive. Different domains often bring different definitions for the same concepts. For instance, the word “engine” in the automotive domain might convey a different meaning in another domain.
When we model our software based on the specific domain we’re in, we talk about domain-driven design. With this approach, the terms used in our source code, such as class names, method names, and so forth, should match concepts in the business domain. Going back to the automotive infotainment system, we might have classes called Radio
, Engine
, Wheel
, MusicTrack
, and so on.
Generally speaking, to structure code like this, programmers must interact with domain experts, to understand the context their software will be working within.
The main aim of domain-driven design is to simplify the creation of complex applications by combining several, smaller, pieces of software into a business model.
Here are the main principles of DDD:
The main building block for business models are entities, or objects that have an important meaning in the domain of interest. Several other object types work on entities. For instance, factories are components that are meant to create a single entity or a group of them, hiding the initialization details from the users.
Similarly, repositories deal with persisting entities somewhere, for example on a database. Lastly, services model specific operations in a business logic.
In the automotive domain, for instance, we may have a MusicTrack
entity modeling a music track stored somewhere in our car infotainment system. A MusicTrackRepository
class could be responsible for storing new music tracks, retrieving information on existing tracks, and deleting them. Similarly, we might have a MusicService
to play selected tracks.
The main benefit of domain-driven design is that the main concepts, or entities, are defined at the very beginning of the project. This leads to easy communication because everything has a fixed meaning and there are no ambiguities.
Furthermore, modeling entities, services, factories, and repositories with objects, while following the principles of object-oriented design, results in a codebase that’s easier to maintain in the long run. Typically, each component has a well-defined scope and responsibility, enhancing the encapsulation and modularity of the software.
However, DDD requires very strong domain-related knowledge. It lends itself to projects with very complex business logic, like our infotainment system.
Projects with very complex technical requirements (e.g., performance) or with a relatively simple business logic (e.g., an embedded system for processing only a few signals) are generally less suitable for DDD.
Domain-driven design is suitable for problems with complex business logic. TypeScript’s powerful type system enables very fine-grained domain modeling. Several TypeScript features are useful in this regard, like record types, union and intersection types, tuples, literal types, and generics.
By leveraging these features when needed, we can write type-safe, readable, and maintainable codebases. A clean and well-defined domain model also offers the benefit of being a useful documentation tool.
One of TypeScript’s primary strengths is that its sophisticated type system lets us pick features as needed, employing and adapting the language to several software design frameworks.
Domain-driven design offers a nice way to design our business model. With DDD, the complexity of the backend grows along with the business requirements, keeping the model and the requirements aligned.
Whether or not this is also true for frontend codebases depends on several factors. Many frontend codebases deal more with technical complexity, like choosing the right technology stack, rather than with the complexity of the business domain.
It’s often preferable to centralize the domain model in the backend, possibly creating subdomains (i.e., projections of the main domain model) dedicated to the frontend. This way, the frontend won’t contain any reference to the business logic.
Some frontend projects may not have a backend, in which case, domain-driven design can be a good choice. Furthermore, it may be beneficial to also leverage micro-frontends, which are quite similar to microservices and enable us to break down a complex frontend into smaller, simpler parts.
When a micro-frontend does not have a backend behind it, leveraging DDD can help further simplify the domain complexity of the application.
To better understand how TypeScript can be used for data-driven design, let’s take a look at a simple application that is used to manage sports competition records.
Entity objects are the main components of the domain model. Here’s an example of an entity from our TypeScript application, recording the best times of the competitors:
class Competition { readonly id: string readonly maleRecordInSeconds: number readonly femaleRecordInSeconds: number constructor(mr: number, fr: number) { this.id = "" this.maleRecordInSeconds = mr this.femaleRecordInSeconds = fr } }
We can use repository components to retrieve entities, or aggregates of entities, from a means of storage, such as a database. The goal of this type of component is to hide the complexity of the underlying storage layer. Ideally, they should be declared as interfaces, so that we can easily swap one concrete implementation for another:
interface CompetitionRepository { getAll(): Competition[] addOne(c: Competition): boolean // more CRUD-based functions here } class MySQLCompetitionRepository implements CompetitionRepository { getAll(): Competition[] { // ... } addOne(c: Competition): boolean { // ... } }
We can use service classes to model operations that do not belong to any other objects. For example, they can be the entry point to model and implement the use cases of our application. In this case, their implementations make use of different repositories to fetch the entities and possibly modify them:
class CompetitionService { function setNewFemaleRecordForCompetition(compId: string, fr: number): Competition { // 1. get competition from DB using MySQLCompetitionRepository // 2. set new record // 3. store new object in the DB // 4. return new object } }
When organizing the TypeScript code, we can divide it into different layers. Here are the most widely used layers:
In this article, we explored the main principles, terminology, and concepts associated with modeling software according to domain-driven design. We discussed cases where DDD is most beneficial, and when it’s more advantageous to look for another modeling pattern.
We also mentioned some lifesaving TypeScript features that turn out to be very useful in modeling complex business domains and explored the idea of applying domain-driven design to frontend and micro-frontend projects.
Even though DDD is more backend-oriented, we can apply its principles and ideas to the frontend world as well. Lastly, we reviewed a simple, yet practical, example of DDD in TypeScript.
Domain-driven design is just a set of guidelines to model our business domain and regulate the way we develop an application. Of course, no design pattern will ever apply to all domains, scenarios, and application types. In fact, each pattern is just another tool on our belt. Modern software design is an iterative process and we, as engineers and developers, can select one pattern over the other.
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]