How to structure your TypeScript Node.js Application
By: Elias Klemm
Tags: Architecture, Ports And Adapters, Nodejs, Typescript, Guides And Tutorials

For me, it´s always hard to start a new application from scratch.

It’s especially tough when coming from frameworks like Ruby on Rails or Symphony, where you get your initial application structure out of the box.

In the world of TypeScript, we tend to refrain from the idea of batteries included and sometimes opinionated frameworks. It’s up to us developers to come up with something great from scratch and it usually takes several iterations to get it right. That’s why I decided to write a small practical guide from my personal experiences on how you can structure your TypeScript Node.js app.

This article will include some hands-on examples, and I will touch on a couple of important basic architectural principles. We are going to use a simplified version of the ports and adapters architecture.

It’s useful if you are already familiar with the following concepts:

You have a good understanding of Typescript.

You know basic OOP concepts such as interfaces and factory functions and should be familiar with polymorphism. We will apply those principles with a functional coding style.

You have a love for software architecture and would like to learn more about it.

I will first introduce the concepts and then go through a hands-on example of how you can apply those while designing your application.

Ports and Adapters

The idea of Ports and Adapters got quite popular over the past decade. It’s a simple but powerful idea on how you can structure your application:

The idea of Ports and Adapters is that the application is central to your system. All the inputs and outputs reach or leave the core of the application through a port. This port isolates the application from external technologies, tools and delivery mechanics. — https://www.thinktocode.com/2018/07/19/ports-and-adapters-architecture/

Let’s look at a simple use case to understand what this means:

A user would like to print a document.

Let’s forget about the real complexity of the problem for a moment and only look at the essential parts: The first thing we need is a printing device that prints the document. We also need the driver that translates your virtual document into low-level and device-specific messages/commands that your printer can understand.

The driver's job is to encapsulate all the logic that the particular printer needs and exposes an abstract version of the printer to the operating system.

There is an excellent reason why we are doing this: The Operating System has to support all kinds of printers. If we make printers pluggable through drivers, we can just add a new one without the need of changing/extending the core of the operating system.

Translating this example into ports and adapters: The driver is the adapter and the abstract printer representation is the port. The adapter can and will change in the future; the port can stay the same.

Your app should be flexible enough to replace certain parts that might get replaced in the future. Also, sometimes you may have different devices for different jobs.

That’s why we don’t want to add anything that is not 100% related directly to your application's core.

Now that we a rough idea of Ports and Adapters, let’s look at the layers:

The Layers

Layers help you to control the data-flow of your application. They create clear boundaries between different types of services and abstraction levels:

In “Ports and Adapters” architecture, you can very often find the following layers:

Infrastructure Layer: Includes all the adapters/third party dependencies. This layer includes database calls such as Postgres, third-party API clients for external APIs, or in our example, printer drivers for a specific printer technology. Those concerns should be separated from the core of your application. In this layer, changes usually occur very often and are very specific to a particular technology and problem.

Includes all the adapters/third party dependencies. This layer includes database calls such as Postgres, third-party API clients for external APIs, or in our example, printer drivers for a specific printer technology. Those concerns should be separated from the core of your application. In this layer, changes usually occur very often and are very specific to a particular technology and problem. Use Cases Layer: This is the place where the magic happens. It includes the use cases of your app—for example, “printDocument” or “createUser.” You can see it as the core of your application. This layer houses the actual application logic.

This is the place where the magic happens. It includes the use cases of your app—for example, “printDocument” or “createUser.” You can see it as the core of your application. This layer houses the actual application logic. Domain Layer: defines the models you are using. For example, “Document” or “Printer.” I typically use this layer only for simple interfaces. Those interfaces describe data objects and don’t define any functionality (no method definitions). Those objects are usually passed between the layers.

defines the models you are using. For example, “Document” or “Printer.” I typically use this layer only for simple interfaces. Those interfaces describe data objects and don’t define any functionality (no method definitions). Those objects are usually passed between the layers. Maybe more: There are no strict rules about how many layers your application should have. Suppose you want to add another “presentation” layer for your API or CLI commands that are perfectly fine. It always depends on what you are building.

Inversion of Control

Have you seen the arrow in the drawing? There is an essential rule that helps you to get the dependencies management right:

Your dependencies should always point from the outside in.

That means that whenever you find yourself importing some service like “postgres” or “mysql” from the infrastructure layer into the use case layer, step back and think twice:

The arrow means that the use case layer shouldn’t have any direct dependencies on the outer layer.

You may ask yourself: How is this achievable? How can I save a user when I’m not allowed to access my database dependency? Here is where word direct comes into play: You will only access your database indirectly through an interface. We are inverting the dependency through “inversion of control”.

Let’s look at the following “create user” example to see how we can achieve “inversion of control”:

Instead of calling the saveUser function from the infrastructure layer directly, we are injecting it into the createUser function. createUser only takes an interface that describes how this function should look like. We are basically composing the functionality from functions of different layers.

This way, we have inverted the relationship between the memory storage and the createUser use case. This is also called dependency injection. You can now replace the memory storage with a database call without changing anything in the use case layer.

Your main function is responsible for composing the relationship between those layers.

TypeScript shines at this technique. Typed languages have, in this case, a clear advantage compared to untyped languages such as JavaScript. Interfaces are a crucial building block to achieve a readable and clean inversion of control.

Here are some more reasons why this is such a useful idea:

When testing the “createUser” method, you can simply pass in a mock of the memory storage.

When you want to replace your Memory Storage with a database, you can do it without touching the use case logic directly.

Whenever you want to change something in one layer, the other layers are not affected.

It’s possible to make decisions independently. You can define, for example, your user creation logic independent from your persistence strategy.

Please also check out this StackOverflow answer, which sums it up quite nicely: https://stackoverflow.com/questions/3058/what-is-inversion-of-control.

Folder Structure

Now it’s time to dig into the folder structure.

I find this aspect particularly difficult. For some reason, it’s just hard for me to move files around until everything fits. Don’t be afraid of changing your initial structure often in the beginning. I usually start with the following structure:

You see the layers and purpose of your application immediately.

The src folder holds all our code. This is typical for TypeScript apps since we also have a couple of config files such as tsconfig.ts, lintrc, babelrc, etc., in the root directory.

The src/useCases folder includes all of your use cases. When you’re opening this folder, you should understand the purpose of your app immediately. Name the use case in such a that it’s clear what it does. I have also seen architectures where the use cases are directly in the src folder along side the infra modules. This can also work. It depends on your taste.

The src/infra folder holds all the infrastructure code. Here you can see folders named by their technology. It’s perfectly fine to have a folder called postgres because it describes exactly what this module is made for.

The src/domain folder holds the data objects as discussed before.

The src/main.ts module is responsible for wiring up your application. It will connect the layers and also bootstrap your application. Your main function can become quite big. It can make sense to split up this logic in an additional layer.

Every folder includes an index.ts file. I use those files to control which functionality should be publicly available to other modules. Only expose functionality to the outside that if it is somewhere else.

Testing: I find it very useful to keep your test files close to the implementation. It adds the benefit that you don’t need to expose any logic to the outside that is not necessary. I won’t cover testing in this article, but it’s undoubtedly useful to keep it in mind.

Let’s print a document.

We are finally ready to implement our first use case. I will walk you through the “printDocument” feature. This example is by no means complete, and all functions would surely be more complicated if this would be a real-life application. Especially the use case would hold more logic than only creating a document. You would maybe notify the user that the document got printed, or drop some metrics to an external service.