|
3 | 3 | The project was created to demonstrate how we can create a modular monolith in Node.js. The main idea of the project was high separation of each module from each other. This allows each module to be developed independently by different teams. As the project develops this will also allow us to easily extract single modules into microservices. For this purpose, I chose a fairly well-known domain and on its example tried to present how the implementation of such an application can look like.
|
4 | 4 | In this file, as well as in the ADR, you will find implementation details as well as decisions that were made during this project. I would like every important point to be documented so that you can easily see the reasoning behind a particular choice.
|
5 | 5 |
|
| 6 | +# Table of contents |
| 7 | + |
| 8 | +- [Travelhoop - Modular monolith in Node.js](#travelhoop---modular-monolith-in-nodejs) |
| 9 | +- [Table of contents](#table-of-contents) |
| 10 | +- [1. Domain](#1-domain) |
| 11 | + - [1.1 About domain](#11-about-domain) |
| 12 | + - [1.2 Event storming](#12-event-storming) |
| 13 | + - [1.2.1 User module](#121-user-module) |
| 14 | + - [1.2.2 Host module](#122-host-module) |
| 15 | + - [1.2.3 Booking module](#123-booking-module) |
| 16 | + - [1.2.4 Review module](#124-review-module) |
| 17 | +- [2. Architecture](#2-architecture) |
| 18 | + - [2.1 Overview](#21-overview) |
| 19 | + - [2.1.1 Event driven architecture](#211-event-driven-architecture) |
| 20 | + - [2.1.2 Vertical slices](#212-vertical-slices) |
| 21 | + - [2.2 Modules](#22-modules) |
| 22 | + - [2.2.1 AppModule interface](#221-appmodule-interface) |
| 23 | + - [2.2.2 Module loader](#222-module-loader) |
| 24 | + - [2.3 Communication](#23-communication) |
| 25 | + - [2.3.1 Message broker](#231-message-broker) |
| 26 | + - [2.4 Architectural Decision Records](#24-architectural-decision-records) |
| 27 | + |
6 | 28 | # 1. Domain
|
7 | 29 |
|
8 |
| -## About domain |
| 30 | +## 1.1 About domain |
9 | 31 | Travelhoop - It is an application thanks to which you can offer free accommodation to travelers. It allows you to make new acquaintances, friendships and meet people from all over the world. Each user can search for accommodation in any location in the world. This is a replication of the fairly well-known Couchsurfing app.
|
10 | 32 |
|
11 |
| -## Event storming |
| 33 | +## 1.2 Event storming |
12 | 34 | To discover the domain and what was behind it, I decided to use a very popular method called [event storming](https://www.eventstorming.com/). Below I present a diagram which was created after one session, on the basis of which the application will be built.
|
13 | 35 |
|
14 | 36 | 
|
15 | 37 |
|
16 |
| -### User module |
| 38 | +### 1.2.1 User module |
17 | 39 | 
|
18 |
| -### Property module |
19 |
| - |
20 |
| -### Booking module |
| 40 | +### 1.2.2 Host module |
| 41 | + |
| 42 | +### 1.2.3 Booking module |
21 | 43 | 
|
22 |
| -### Review module |
| 44 | +### 1.2.4 Review module |
23 | 45 | 
|
24 | 46 |
|
25 | 47 | # 2. Architecture
|
26 | 48 |
|
27 |
| -## Architectural Decision Records |
| 49 | +## 2.1 Overview |
| 50 | + |
| 51 | +### 2.1.1 Event driven architecture |
| 52 | +To keep the independence of the modules high, I decided to use an event driven approach. It will allow us to synchronize the modules with each other asynchronously. It will also allow for low coupling between them. We will exchange messages (events) between modules. They will carry information about changes that occurred in one of the modules, so that the rest of the modules can react in an appropriate way, updating their state or performing some other operation. |
| 53 | + |
| 54 | +### 2.1.2 Vertical slices |
| 55 | +TBU |
| 56 | +## 2.2 Modules |
| 57 | + |
| 58 | +### 2.2.1 AppModule interface |
| 59 | +Each module expose implementation of the following interface: |
| 60 | + |
| 61 | +```ts |
| 62 | +export interface UseDependencies { |
| 63 | + dbConnection: DbConnection; |
| 64 | + redis: Redis; |
| 65 | +} |
| 66 | +export interface AppModule { |
| 67 | + basePath: string; |
| 68 | + name: string; |
| 69 | + path: string; |
| 70 | + use: (app: Application, deps: UseDependencies) => void; |
| 71 | + dispatchEvent(event: Event): Promise<void>; |
| 72 | +} |
| 73 | +``` |
| 74 | + |
| 75 | +Here we define: |
| 76 | +- **basePath** - for a routing purposes we need to define main route for each module. It will help us orginise the module under specific paths |
| 77 | +- **name** - the name of the module. By the implementation of module loader, this should be the name like *some-module* |
| 78 | +- **use(app: Application, deps: UseDependencies)** - it's a method which we later use in our `app.ts` file to load whole module. Here we are passing common dependencies like connection to databases, which are shared across all modules. We can also add here our custom error handling, specific for each module, or some other middlewares. What is important, all middlewares defined here, will be a part of the whole pipeline of our express application. So be careful. |
| 79 | +- **dispatchEvent(event: Event)** - it's a method, which will be later used by our background message dispatcher, to pass integration events from e.g. queue to our module. |
| 80 | + |
| 81 | +### 2.2.2 Module loader |
| 82 | +In `server.ts` which is a main file of our application we are using module loader to load all the modules and register it into container. |
| 83 | +Next, registered modules are injected into app.ts, where we define an express execution pipeline. There we are registering all the modules, by executing their `use` method: |
| 84 | + |
| 85 | +```ts |
| 86 | +export const createApp = ({ errorHandler, modules, dbConnection, redis }: AppDependencies): Application => { |
| 87 | + (...) |
| 88 | + |
| 89 | + modules.forEach(m => m.use(app, { dbConnection, redis })); |
| 90 | + |
| 91 | + (...) |
| 92 | +``` |
| 93 | +
|
| 94 | +What is important, to correctly register module in our application we need to define a dependency to this module in `package.json`. Also the name of the module folder must follow this convention *some-module*. E.g. for user module, the folder name must be *user-module*. Under the hood we are loading modules interface by looking up for specific directories in our `/node_modules/@travelhoop` folder. |
| 95 | +
|
| 96 | +## 2.3 Communication |
| 97 | +
|
| 98 | +### 2.3.1 Message broker |
| 99 | +
|
| 100 | +This is the main unit responsible for asynchronous message exchange between modules. It issues the `publish` method responsible for publishing them. Internally, the message broker can implement different types of `MessageDispatcher`. |
| 101 | +
|
| 102 | +```ts |
| 103 | +interface MessageBrokerDependencies { |
| 104 | + messageDispatcher: MessageDispatcher; |
| 105 | +} |
| 106 | + |
| 107 | +export class MessageBroker { |
| 108 | + constructor(private readonly deps: MessageBrokerDependencies) {} |
| 109 | + |
| 110 | + async publish<TMessage extends object>(message: TMessage) { |
| 111 | + await this.deps.messageDispatcher.publish(JSON.stringify(message)); |
| 112 | + } |
| 113 | +} |
| 114 | +``` |
| 115 | +
|
| 116 | +Message dispatcher is a simply interface, implemented in various of way. |
| 117 | +
|
| 118 | +```ts |
| 119 | +export interface MessageDispatcher { |
| 120 | + publish(message: string): Promise<void>; |
| 121 | +} |
| 122 | +``` |
| 123 | +
|
| 124 | +One of the implementaton is `RedisMessageDispatcher`. Under the hood it use a Redis queue to push a message to the consumers. |
| 125 | +Since our application is a single deployment unit, `MessageBroker` publishes messages to a single, common queue for all modules. The message is then retrieved by a single consumer. The implementation of the consumer can be found here `/modular-monolith/src/shared/infrastructure/src/messaging/background.message-dispatcher.ts`. It listen for a new message on queue and dispatch it to all the modules, by their interface. |
| 126 | +Responsibility of each module is to decide if the message should be handled or not. Our main application doesn't care about it. Our modules are responsible for themselve. |
| 127 | +
|
| 128 | +## 2.4 Architectural Decision Records |
28 | 129 | All architectural decisions are keeped in `./docs/adr` directory. It captures an important architectural decision made along with its context and consequences. To automate this process I've used [adr-tools](https://github.com/npryce/adr-tools) library. It should help you understand, why I've made some decisions in this project. Very often we have a couple of possibility how to solve some problems. Often it has some pros and cons. It is important to make this decisions, knowing potential consequences.
|
0 commit comments