A sensible approach to Domain Modelling in TypeScript
by Giovanni Chiodi
7 min read
We're entering a world of cloud-native application development, where anything we develop is likely to be distributed, modular and build to re-use; deployed to the cloud and integrated with a number of different cloud services; where architectures are potentially complex; and where the ability of controlling and reducing such complexity, and the capacity of leveraging AI support effectively, are the discriminants between failure and success of software development projects.
It's a new world compared to when Node.js was released in 2009 and armies of coders (like myself) could spin up a new full-stack MVC project in weeks, moving-fast-and-breaking-things, with the MEAN Stack, the MERN Stack, without sparing any thought about software design and architecture beyond MVC - and that was an advantage. No big-upfront-design, no OOP patterns, and no monads either, no dependency inversion, no ports-and-adapters. Node.js and the MVC paradigm were competitive and disruptive, for a while. But I'm afraid they don't cut it anymore in this new cloud-native, AI-driven world.
Evolving from frameworks and the MVC
"Enabling the Node.js community to face these new challenges entails taking full advantage of TypeScript and fully commit to better software design and more intentional and decoupled architectures."
Enabling the Node.js community to face these new challenges entails taking full advantage of TypeScript and fully commit to better software design and more intentional and decoupled architectures. We need to align TypeScript development to the cloud-native reality and what it entails: complex but controlled architectures, designed intentionally, striving for advanced security, portability and vendor-neutrality, and interoperability. It's a long shot from MVC, and we need to find a way to make all this natural and comfortable to Node.js developers. If we succeed, we're facing a new era of competitiveness and disruption. If not, we'll be relegated to developing PoCs and small-scale projects, only one step ahead of WordPress.
One very crucial aspect of this journey would be researching how to implement sensibly in TypeScript domain-centric architectures (such as Onion, Clean or Hexagonal), without stepping too far out of the legacy Node.js-way. In this effort, identifying the correct approach to domain modelling takes the central stage, as I'll explain later. I believe I found one possible way, and if you bare with me I will try to share it. But first of all, I want to clarify why the Node.js community lags behind and what specific challenges we are facing in preparing Node.js for domain-centric architectures.
Domain-centric Architectures
All the most recognised and recommended software architecture types - namely Clean Architecture, Onion Architecture, and Hexagonal Architecture - attempt to decouple the different concerns within software components - and that is precisely the reason why they're valuable. Decoupling is one of the main objective of software design, as it affects heavily the cost of implementation and maintenance (Kent Beck docet). Typically, the concerns to decouple within a software component are:
-
The Application Concern
- How does our component communicate with the external world?
-
The Infrastructure Concern
- How does our component persists data?
- What other infrastructure is being used?
-
The Domain Concern
- What business logic is our component implementing?
- What real-world entities is the component built to manage?
The Domain Concern, hence the Domain Layer, occupies the central stage in all of these architectures. The domain model within the domain layer encapsulates the character and business specifications of the software component, and establishes trust boundaries and decoupling from both APIs and infrastructure through dependency-injection and dependency-inversion. While this might be commonly acknowledged in some communities (e.g. Java, .NET), better acquainted with Domain-driven Design and OOP design patterns, the average Node.js developer usually jumps straight in to linking applications concerns with infrastructure concerns through transaction scripts.
For example, implementing a "submit order
" feature, a Node.js developer might create a POST submit-order
endpoint, an OrderModel
using any ORM, and write code in the endpoint controller to process the request and update the DB using the ORM, implementing any business logic iffing the hell out of the controller.
This is fast, and enables Node.js apps to go from idea to production faster than other ecosystems. However, after the initial successful speed-run, coupling makes it too complex to implement new features within the codebase, or fixing bugs without creating new ones, or simply understanding what the - if - is going on. In my experience, the typical lifecycle of a Node.js app includes the need of being entirely re-written. That is expensive, and often unsuccessful.
"The typical lifecycle of a Node.js app includes the need of being entirely re-written."
Why Node.js is not (traditionally) domain-centric
There are a number of reason for this, specific to the Node.js community. Some are:
-
The move fast, break things mentality
- As Node.js developers we want to move fast, we hate boilerplates or any hindrance to speed, we're sometimes even proud of our technical debt because it shows how fast we moved, and we deal with it with agility and refactoring rather than design
-
The MVC paradigm in JS frameworks
- We use frameworks and we identify our skills as developers with our skills in using such frameworks (e.g. React Developer, Nest.js Developer, Angular Developer), and all these frameworks have educated us (entrapped?) in to the MVC paradigm
- We struggle to see ourselves as simply JavaScript developers, able of using the programming language and its features beyond the shackles of our frameworks of choice
- Frameworks have now lost their relevance, from one hand simply due to the sheer number of them, and from the other due to the industry pressure for something more evolved than MVC
-
The sympathy for Functional Programming
- Our most notable stylistic evolution throughout the years is the recognition of immutability, unidirectional data flows, and composition, as our main weapons against chaos; this makes us naturally inclined towards Functional Programming, although we lack the means in our language for going full-functional
- Because of all the above, Object Oriented Programming feels unnatural to us: it slows us down, it forces us to design rather than refactoring, it might introduce mutability or worse inheritance, and we lack an extensive cultural toolbox of OOP Patterns, more accessible to, say, a Java developer
- As of recently, most techniques for domain modelling used OOP
-
JavaScript before TypeScript, Node.js before Deno
- For many years before TypeScript was released, our language lacked type-safety and had some weird implementation of classes; Even tho we have now, with TypeScript, a reasonable language to work with, as serious and powerful as any other language, we are still affected by heritage good-old.js mentalities and practices that prevent us from taking full advantage of TypeScript
- Using TypeScript itself has been so far (2024) somewhat cumbersome in Node.js, that further prevented many developers to embrace it; with the release of Bun, and more recently and importantly Deno 2.0, TypeScript is our default language, and it's time to leave the familiar shores of MVC and fully commit to better software design and better software architecture (i.e. separate Application Concerns, Infrastructure Concerns, and Domain Concerns, a.k.a become domain-centric)
"With Deno 2.0 and TypeScript by default, it's time to leave the familiar shores of MVC and fully commit to better software design and better software architecture, a.k.a. become domain-centric."
The way forward
While it could be possible now to easily dive in to OOP domain modelling in TypeScript, that would still go against the grain of what Node.js has stood for in all these years, would encounter resistance from developers, and would entail quite a learning curve. Imagine a rock-star developer domain modelling the hell out of a project using by memory all the best Eric Evans, Rebecca Wirfs-Brock, and Martin Fowler OOP patterns. Then, imagine the rest of the Express.js team trying to make sense of it. But if you want to learn how, there's a good workshop offered by DDD Europe - I did it last year, and I recommend it.
"Imagine a rock-star developer domain modelling the hell out of a project using by memory all of the best Eric Evans, Rebecca Wirfs-Brock, and Martin Fowler OOP patterns. Then, imagine the rest of the Express.js team trying to make sense of it".
On the other hand, there are now available also Functional approaches to domain modelling, notably in F# thanks to the enlightening work of Scott Wlaschin. However, TypeScript is no F#, and functional programming is more than immutability, despite what some "functional programmers" in the Node.js community might think. If you have ever seen a real functional TypeScript codebase - and I thank Matteo Baglini for teaching me @Avanscoperta - you would know that it requires specific libraries, such as fp-ts and others, and while I personally think it is a step ahead compared to OOP in terms of linearity, legibility, and composability, that still requires proficiency with FP-specific mental gymnastic, and overall it still doesn't quite look like plain TypeScript. But: there's without a doubt more potential, I believe, and more alignment with the legacy Node.js-way, in the FP approach rather than the OOP one.
To support this claim, I would argue that in Node.js we tend not write classes - except in Angular and Nest.js, but even there we're not sophisticated, and we go along with whatever the framework offers in terms of built-in OOP patterns and dependency management. Generally, we just write functions, and occasional interfaces. This is, apparently, not lost on the community, and different interesting approaches have emerged taking inspiration from FP but simplifying and adapting to TypeScript and Node.js. Notably, I'm a great fan of Anthony Manning-Franklin's approach, because it does feels node-like. That's the right path! Another example on the same path is this one by Craig McCallum.
I'm also deep into this very promising path and searching for something even more node-like and with a lower entry barrier. Something distilled and pragmatic enough to be adopted effortlessly by Node.js teams, without encountering resistance and without learning curves, without any external library but only using plain TypeScript, and only the simple parts, while making the codebase simpler rather than adding complexity. I will stick to these intents and principles while I'll keep experimenting, and I'll share more next time.