Sairyss / domain-driven-hexagon
- вторник, 23 февраля 2021 г. в 00:27:28
TypeScript
Guide on Domain-Driven Design, Hexagonal architecture, best practices etc.
This repo is work in progress
Main emphasis of this project is to provide recommendations on how to design software applications. In this readme are presented some of the techniques, tools, best practices, architectural patterns and guidelines gathered from different sources.
Everything below should be seen as a recommendation. Keep in mind that different projects have different requirements, so any pattern mentioned in this readme can be replaced or skipped if needed.
Code examples are written using NodeJS, TypeScript, NestJS framework and Typeorm for the database access.
Though patterns and principles presented here are framework/language agnostic, so above technologies can be easily replaced with any alternative. No matter what language or framework is used, any application can benefit from principles described below.
Note: code examples are adapted to TypeScript and mentioned above frameworks so may not fit well for other languages. Also remember that code examples presented here are just examples and must be changed according to project's needs or personal preference.
Other recommendations and best practices
Mainly based on:
And many other sources (more links below in every chapter).
Before we begin, here are the PROS and CONS of using a complete architecture like this:
This is a sophisticated architecture which requires a firm understanding of quality software principles, such as SOLID, Clean/Hexagonal Architecture, Domain-Driven Design, etc. Any team implementing such a solution will almost certainly require an expert to drive the solution and keep it from evolving the wrong way and accumulating technical debt.
Some of the practices presented here are not recommended for small-medium sized applications with not a lot of business logic. There is added up-front complexity to support all those building blocks and layers, boilerplate code, abstractions, data mapping etc. thus implementing a complete architecture like this is generally ill-suited to simple CRUD applications and could over-complicate such solutions. Some of the described below principles can be used in a smaller sized applications but must be implemented only after analyzing and understanding all pros and cons.
Diagram is mostly based on this one + others found online
In short, data flow looks like this (from left to right):
Keep in mind that different projects can have more or less steps/layers/building blocks then described here. Add more if application requires it, and skip some if application is not that complex and doesn't need all that abstraction.
General recommendation for any project: analyze how big/complex the application will be, find a compromise and use as many layers/building blocks as needed for the project and skip ones that may over-complicate things.
More in details on each step below.
This project's code examples use separation by modules (also called components). Each module gets its own folder with a dedicated codebase, and each use case inside that module gets it's own folder to store most of the things it needs (this is also called Vertical Slicing).
It is easier to work on things that change together if those things are gathered relatively close to each other. Try not to create dependencies between modules or use cases, move shared logic into a separate files and make both depend on that instead of depending on each other.
Try to make every module independent and keep interactions between modules minimal. Think of each module as a mini application bounded by a single context. Try to avoid direct imports between modules (like importing a service from other domain) since this creates tight coupling. Communication between modules can be done using events, public interfaces or through a port/adapter (more on that topic below).
This approach ensures loose coupling, and, if bounded contexts are defined and designed properly, each module can be easily separated into a microservice if needed without touching any domain logic.
A lof of people tend to create one module per entity, but this approach is not very good. Each module may have multiple entities. One thing to keep in mind is that putting entities in a single module requires those entities to have related business logic, don't group unrelated entities in one module.
Read more about modular programming benefits:
Each module is separated in layers described below.
This is the core of the system which is built using DDD building blocks.
Dependencies point inwards. Outer layers can depend on inner layers, but inner layers never depend on outer layers.
Core layers shouldn't depend on frameworks or access external resources. Any external calls to out-of-process resources/retrieval of data from remote processes should be done through ports
(interfaces), with class implementations created somewhere in infrastructure layer and injected into application's core (Dependency Injection and Dependency Inversion).
This is just a short list the main things that may reside in here. More building blocks may be added if needed.
Are also called "Workflow Services", "User Cases", "Interactors" etc. These services orchestrate the steps required to fulfill the commands imposed by the client.
Entities
(or anything else) from database/outside world through ports;Ports
(like event emits, sending emails etc);Domain Service
to orchestrate them;Command
/Query
handlers;One service per use case is considered a good practice.
wiki:
In software and systems engineering, a use case is a list of actions or event steps typically defining the interactions between a role (known in the Unified Modeling Language as an actor) and a system to achieve a goal.
Use cases are, simply said, list of actions required from an application.
Example file: create-user.service.ts
More about services:
Some people prefer having an interface for each use case (Driving Port), which Application Service
implements and a Controller
depends on. This is a viable option, but this project doesn't use interfaces for every use case for simplicity: it makes sense using interfaces when there are multiple implementations of a workflow, but use cases are too specific and should not have multiple implementations of the same workflow (one service per use case rule mentioned above). Controllers
naturally depend on a concrete implementation thus making interfaces redundant. More on this topic here.
Another thing that can be seen in some projects is local DTOs. Some people prefer never use domain objects (like entities) outside of core (in controllers
, for example), and are using DTOs instead. This project doesn't use this technique to avoid extra interfaces and data mapping. Either to use local DTOs or not is a matter of taste.
Here are Martin Fowler's thoughts on local DTOs, in short (quote):
Some people argue for them(DTOs) as part of a Service Layer API because they ensure that service layer clients aren't dependent upon an underlying Domain Model. While that may be handy, I don't think it's worth the cost of all of that data mapping.
This principle is called Command–Query Separation(CQS). When possible, methods should be separated into Commands
(state-changing operations) and Queries
(data-retrieval operations). To make a clear distinction between those two types of operations, input objects can be represented as Commands
and Queries
. Before DTO reaches the domain, it is converted into a Command
/Query
object.
Commands
are used for state-changing actions, like creating new user and saving it to the database. Create, Update and Delete operations are considered as state-changing.Data retrieval is responsibility of Queries
, so Command
methods should not return anything. Though, if needed, returning a bare minimum (like ID
of a created item or a confirmation message) may not be a bad idea.
Note: Command
has nothing to do with Command Pattern, it is just a convenient name to represent that this object invokes a CQS Command. Both Commands
and Queries
in this example are just simple objects with data.
Example of command object: create-user.command.ts
Query
is used for retrieving data and should not make any state changes (like writes to the database, files etc).Queries are usually just a data retrieval operation and have no business logic involved; so, if needed, application and domain layers can be bypassed completely. Though, if some additional non-state changing logic has to be applied before returning a query response (like calculating something), it should be done in a corresponding application service.
Validation also can be skipped, since no input is persisted in query operations. But, if needed, it can be validated to tell the user that query format is incorrect (when using enums for example).
Example of query bypassing application/domain layers completely: find-user-by-email.http.controller.ts
Note: Some simple cases may not need a Command
/Query
object, like find query or delete command may only need an ID so there is no point in creating an object for that.
Read more about CQS:
Ports (for Driven Adapters) are interfaces that define contracts which must be implemented by infrastructure adapters in order to execute some action more related to technology details rather then business logic. Ports act like abstractions for technology details that business logic does not care about.
Note: since most ports implementations are injected and executed in application service, Application Layer can be a good place to keep those ports. But there are times when Domain Layer's business logic depends on executing some external resource, in that case those ports can be put in a Domain Layer.
Example file: repository.ports.ts
This layer contains application's business rules.
Domain should only operate using domain objects, most important ones are described below.
Entities are the core of the domain. They encapsulate Enterprise wide business rules and attributes. An entity can be an object with properties and methods, or it can be a set of data structures and functions.
Domain business logic goes here. Avoid having business logic in your services when possible, this leads to Anemic Domain Model (domain services are exception for business logic that can't be put in a single entity).
Domain entities should always be valid entities. There are a certain number of invariants for an object that should always be true. For example, an order item object always has to have a quantity that must be a positive integer, plus an article name and price. Therefore, invariants enforcement is the responsibility of the domain entities (especially of the aggregate root) and an entity object should not be able to exist without being valid.
Entities:
id
field).Example files:
Read more:
Aggregate is a cluster of domain objects that can be treated as a single unit. It encapsulates entities and value objects which conceptually belong together. It also contains a set of operations which those domain objects can be operated on.
Domain Events
(more on that below).Example files: aggregate-root.base.ts
Read more:
Domain event indicates that something happened in a domain that you want other parts of the same domain (in-process) to be aware of.
For example, if a user buys something, you may want to:
Typical approach that is usually used involves executing all this logic in a service that performs a buy operation. But this creates coupling between different subdomains.
A better approach would be publishing a Domain Event
. Any side effect operations can be performed just by subscribing to a concrete Domain Event
and creating as many event handlers as needed, without glueing any unrelated code to original domain's service that sends an event.
Domain events are just messages pushed to a domain event dispatcher in the same process. Out-of-process communications (like microservices) are called Integration Events. If sending a Domain Event to external process is needed then domain event handler should send an Integration Event
.
Domain Events may be useful for creating an audit log to track all changes to important entities by saving each event to the database. Read more on why audit logs may be useful: Why soft deletes are evil and what to do instead.
There may be different ways on implementing Domain Events, for example using some kind of internal event bus/emitter, like Event Emitter, or using patterns like Mediator or slightly modified Observer.
Examples:
Events can be published right before or right after insert/update/delete transaction, chose any option that is better for a particular project:
Both options have pros and cons.
Note: this project uses custom implementation for Domain Events. Reason for not using Node Event Emitter is that event emitter executes events immediately when called instead of when we want it (before/after transaction), and also has no option to await
for all events to finish, which might be useful when making those events a part of transaction.
To have a better understanding on domain events and implementation read this:
For integration events in distributed systems here are some useful patterns:
Eric Evans, Domain-Driven Design:
Domain services are used for "a significant process or transformation in the domain that is not a natural responsibility of an ENTITY or VALUE OBJECT"
Entities
.Entity
would break encapsulation and require the Entity
to know about things it really shouldn't be concerned with.Some Attributes and behaviors can be moved out of the entity itself and put into Value Objects
.
Value Objects:
entities
and other value objects
.Value object shouldn’t be just a convenient grouping of attributes but should form a well-defined concept in the domain model. This is true even if it contains only one attribute. When modeled as a conceptual whole, it carries meaning when passed around, and it can uphold its constraints.
Imagine you have a User
entity which needs to have an address
of a user. Usually an address is simply a complex value that has no identity in the domain and is composed of multiple other values, like country
, street
, postalCode
etc; so it can be modeled and treated as a Value Object
with it's own business logic.
Value object
isn’t just a data structure that holds values. It can also encapsulate logic associated with the concept it represents.
Example files:
Read more about Value Objects:
Most of the code bases operate on primitive types – strings
, numbers
etc. In the Domain Model, this level of abstraction may be too low.
Significant business concepts can be expressed using specific types and classes. Value Objects
can be used instead primitives to avoid primitives obsession.
So, for example, email
of type string
:
email: string;
could be represented as a Value Object
instead:
email: Email;
Now the only way to make an email
is to create a new instance of Email
class first, this ensures it will be validated on creation and a wrong value won't get into Entities
.
Also an important behavior of the domain primitive is encapsulated in one place. By having the domain primitive own and control domain operations, you reduce the risk of bugs caused by lack of detailed domain knowledge of the concepts involved in the operation.
Creating an object for primitive values may be cumbersome, but it somewhat forces a developer to study domain more in details instead of just throwing a primitive type without even thinking what that value represents in domain.
Using Value Objects
for primitive types is also called a domain primitive
. The concept and naming are proposed in the book "Secure by Design".
Using Value Objects
instead of primitives:
string
.Also an alternative for creating an object may be a type alias just to give this primitive a semantic meaning.
Example files:
Recommended to read:
Use Value Objects/Domain Primitives and Types system to make illegal states unrepresentable in your program.
Some people recommend using objects for every value:
Quote from John A De Goes:
Making illegal states unrepresentable is all about statically proving that all runtime values (without exception) correspond to valid objects in the business domain. The effect of this technique on eliminating meaningless runtime states is astounding and cannot be overstated.
Lets distinguish two types of protection from illegal states: at compile time and at runtime.
Types give useful semantic information to a developer. Good code should be easy to use correctly, and hard to use incorrectly. Types system can be a good help for that. It can prevent some nasty errors at a compile time, so IDE will show type errors right away.
The simplest example may be using enums instead of constants, and use those enums as input type for something. When passing anything that is not intended IDE will show a type error.
Or, for example, imagine that business logic requires to have contact info of a person by either having email
, or phone
, or both. Both email
and phone
could be represented as optional, for example:
interface ContactInfo {
email?: Email;
phone?: Phone;
}
But what happens if both are not provided by a programmer? Business rule violated. Illegal state allowed.
Solution: this could be presented as a union type
type ContactInfo = Email | Phone | [Email, Phone];
Now only either Email
, or Phone
, or both must be provided. If nothing is provided IDE will show a type error right away. This is a business rule validation used at compile time.
This approach can be used to make business logic safer and get an error as fast as possible (at compile time).
Things that can't be validated at compile time (like user input) are validated at runtime.
Domain objects have to protect their invariants. Having some validation rules here will protect their state from corruption.
Value Object
can represent a typed value in domain (a domain primitive). The goal here is to encapsulate validations and business logic related only to the represented fields and make it impossible to pass around raw values by forcing a creation of valid Value Objects
first. This object only accepts values which make sense in its context.
If every argument and return value of a method is valid by definition, you’ll have input and output validation in every single method in your codebase without any extra effort. This will make application more resilient to errors and will protect it from a whole class of bugs and security vulnerabilities caused by invalid input data.
Data should not be trusted. There are a lot of cases when invalid data may end up in a domain. For example, if data comes from external API, database, or if it's just a programmer error.
Enforcing self-validation will inform immediately when data is corrupted. Not validating domain objects allows them to be in an incorrect state, this leads to problems.
Without domain primitives, the remaining code needs to take care of validation, formatting, comparing, and lots of other details. Entities represent long-lived objects with a distinguished identity, such as articles in a news feed, rooms in a hotel, and shopping carts in online sales. The functionality in a system often centers around changing the state of these objects: hotel rooms are booked, shopping cart contents are paid for, and so on. Sooner or later the flow of control will be guided to some code representing these entities. And if all the data is transmitted as generic types such as int or String , responsibilities fall on the entity code to validate, compare, and format the data, among other tasks. The entity code will be burdened with a lot of tasks, rather than focusing on the central business flow-of-state changes that it models. Using domain primitives can counteract the tendency for entities to grow overly complex.
Quote from: Secure by design: Chapter 5.3 Standing on the shoulders of domain primitives
Note: Though primitive obsession is a code smell, some people consider making a class/object for every primitive may be an overengineering. For less complex and smaller projects it definitely may be. For bigger projects, there are people who advocate for and against this approach. If creating a class for every primitive is not preferred, create classes just for those primitives that have specific rules or behavior, or just validate only outside of domain using some validation framework. Here are some thoughts on this topic: From Primitive Obsession to Domain Modelling - Over-engineering?.
Recommended to read:
For simple validation like checking for nulls, empty arrays, input length etc. a library of guards can be created.
Example file: guard.ts
Read more: Refactoring: Guard Clauses
Another solution would be using an external validation library, but it is not a good practice to tie domain to external libraries and is not usually recommended.
Although exceptions can be made if needed, especially for very specific validation libraries that validate only one thing (like specific IDs, for example bitcoin wallet address). Tying only one or just few Value Objects
to such a specific library won't cause any harm. Unlike general purpose validation libraries which will be tied to domain everywhere and it will be troublesome to change it in every Value Object
in case when old library is no longer maintained, contains critical bugs or is compromised by hackers etc.
Though, it is fine to do full sanity checks using validation framework or library outside of domain (for example class-validator decorators in DTOs
), and do only some basic checks inside of Value Objects
(besides business rules), like checking for null
or undefined
, checking length, matching against simple regexp etc. to check if value makes sense and for extra security.
Be careful with custom regexp validations for things like validating email
, only use custom regexp for some very simple rules and, if possible, let validation library do it's job on more difficult ones to avoid problems in case your regexp is not good enough.
Also, keep in mind that custom regexp that does same type of validation that is already done by validation library outside of domain may create conflicts between your regexp and the one used by a validation library.
For example, value can be accepted as valid by a validation library, but Value Object
may throw an error because custom regexp is not good enough (validating email
is more complex then just copy - pasting a regular expression found in google. Though, it can be validated by a simple rule that is true all the time and won't cause any conflicts, like every email
must contain an @
). Try finding and validating only patterns that won't cause conflicts.
Although there are other strategies on how to do validation inside domain, like passing validation schema as a dependency when creating new Value Object
, but this creates extra complexity.
Either to use external library/framework for validation inside domain or not is a tradeoff, analyze all the pros and cons and choose what is more appropriate for current application.
For some projects, especially smaller ones, it might be easier and more appropriate to just use validation library/framework.
Keep in mind that not all validations can be done in a single Value Object
, it should validate only rules shared by all contexts. There are cases when validation may be different depending on a context, or one field may involve another field, or even a different entity. Handle those cases accordingly.
There are some general recommendations for validation order. Cheap operations like checking for null/undefined and checking length of data come early in the list, and more expensive operations that require calling the database come later.
Preferably in this order:
Read more about validation types described above:
Whether or not to use libraries in a core/domain is a subject of a lot of debates. In real world, injecting every library instead of importing it directly is not always practical, so exceptions can be made for some single responsibility libraries that help to implement domain logic (like number converting libraries etc). Read more: referencing external libs.
Main recommendations to keep in mind is that libraries imported in application's core/domain shouldn't expose:
To use such libraries consider creating an anti-corruption
layer by using adapter or facade patterns.
Read more:
Be careful with general purpose libraries/frameworks that may scatter across many domain objects. It will be hard to replace those libraries if needed.
Tying only one or just few domain objects to some single-responsibility library should be fine. It is way easier to replace a specific library that is tied to one or few objects then a general purpose library that is everywhere.
Offload as much of irrelevant responsibilities as possible from the core and especially from domain layer.
Interface adapters (also called driving/primary adapters) are user-facing interfaces that take input data from the user and repackage it in a form that is convenient for the use cases(services) and entities. Then they take the output from those use cases and entities and repackage it in a form that is convenient for displaying it back for the user. User can be either a person using an application or another server.
Contains Controllers
and Request
/Response
DTOs (can also contain Views
, like backend-generated HTML templates, if required).
One controller per trigger type can be used to have a more clear separation. For example:
Data Transfer Object (DTO) is an object that carries data between processes.
Input data sent by a user. May consist of request classes and interfaces.
Examples:
Output data returned to a user. May consist of a Request
/Response
class, interface and/or mapper.
Examples:
Response
prefer whitelisting properties over blacklisting using mapper (or right in the Response
class in some cases). This ensures that no sensitive data will leak in case if programmer forgets to blacklist newly added properties that shouldn't be returned to the user.Request
/Response
objects should be kept somewhere in shared directory instead of module directory since they may be used by a different application (like front-end page, mobile app or microservice). Consider creating git submodule or a separate package for sharing interfaces.Request
/Response
DTO classes may be a good place to use validation and sanitization decorators like class-validator and class-sanitizer (make sure that all validation errors are gathered first and only then return them to the user, this is called Notification pattern. Class-validator does this by default).Request
/Response
DTO classes may also be a good place to use Swagger/OpenAPI library decorators that NestJS provides.The Infrastructure is responsible strictly to keep technology. You can find there the implementations of database repositories for business entities, message brokers, I/O components, dependency injection, frameworks and any other thing that represents a detail for the architecture, mostly framework dependent, external dependencies, and so on.
It's the most volatile layer. Since the things in this layer are so likely to change, they are kept as far away as possible from the more stable domain layers. Because they are kept separate, it's relatively easy make changes or swap one component for another.
Infrastructure layer can contain Adapters
, database related files like Repositories
, ORM entities
/Schemas
, framework related files etc.
Read more on ACL: Anti-Corruption Layer: How to Keep Legacy Support from Breaking New Systems
Adapters should have:
port
somewhere in application/domain layer that it implements;Value Objects
).Repositories centralize common data access functionality. They encapsulate the logic required to access that data. Entities/aggregates can be put into a repository and then retrieved at a later time without domain even knowing where data is saved, in a database, or a file, or some other source.
We use repositories to decouple the infrastructure or technology used to access databases from the domain model layer.
Martin Fowler describes a repository as follows:
A repository performs the tasks of an intermediary between the domain model layers and data mapping, acting in a similar way to a set of domain objects in memory. Client objects declaratively build queries and send them to the repositories for answers. Conceptually, a repository encapsulates a set of objects stored in the database and operations that can be performed on them, providing a way that is closer to the persistence layer. Repositories, also, support the purpose of separating, clearly and in one direction, the dependency between the work domain and the data allocation or mapping.
The data flow here looks something like this: repository receives a domain Entity
from application service, maps it to database schema/ORM format, does required operations and maps it back to domain Entity
and returns it back to service.
Keep in mind that application's core is not allowed to depend on repositories directly, instead it depends on abstractions (ports/interfaces). This makes data retrieval technology-agnostic.
This project contains abstract repository class that allows to make basic CRUD operations: typeorm.repository.base.ts. This base class is then extended by a specific repository, and all specific operations that an entity may need is implemented in that specific repo: user.repository.ts.
Using a single entity for domain logic and database concerns leads to a database-centric architecture. In DDD world domain model and persistance model should be separated. If ORM frameworks are used, ORM Entities
can be created to represent domain entities in a database.
Since domain Entities
have their data modeled so that it best accommodates domain logic, it may be not in the best shape to save in database. For that purpose ORM Entities
(or Schemas
) are used that have shape that is better represented in a particular database that is used.
This approach can also be useful when amount of data in database grows and there is a need for re-design of tables (or even database change) to improve performance. When ORM Entities
/Schemas
are separated from Entities
you don't need to touch any domain logic if something in database changes, thus avoiding potential bugs.
Note: separating Entities
and ORM Entities
may be an overkill for smaller applications, consider all pros and cons before making this decision.
Example files:
ORM Entities
should also have a corresponding mapper to map from domain to persistence and back.Read more:
Be careful when implementing any complex architecture in small-medium sized projects with not a lot of business logic. Some of the building blocks/patterns may fit well, but others may be an overengineering.
For example:
Value Objects
to separate business logic into smaller classes, dividing Entities
and ORM Entities
etc. in projects that are more data-centric and have little or no business logic may only complicate such solutions and add extra boilerplate code, data mapping etc. without adding much benefit.Some principles/patterns can be implemented in a simplified form, some can be skipped. Follow YAGNI principle and don't overengineer.
Before implementing any pattern always analyze if benefit given by using it worth extra code complexity.
Effective design argues that we need to know the price of a pattern is worth paying - that's its own skill.
However, remember:
It's easier to refactor over-design than it is to refactor no design.
Read more:
Consider extending Error
object to make custom exception types for different situations. For example: DomainException
etc. This is especially relevant in NodeJS world since there is no exceptions for different situations by default.
Keep in mind that application's core
shouldn't throw HTTP exceptions or statuses since it shouldn't know anything about where it is used, since it can be used by anything: HTTP, Microservice, CLI etc. To return proper HTTP code back to user an instanceof
check can be performed in exception interceptor and appropriate HTTP exception can be returned depending on exception type.
Exception interceptor example: exception.interceptor.ts
Adding a name
string with type name for every exception is a good practice, since when that exception is transferred to another process instanceof
check cannot be performed anymore so a name
string is used instead. Store exception name
enum types in a separate file so they can be reused on a receiving side.
When using microservices, all exception types can be packed into a library and reused in each microservice for consistency.
For example:
400 Bad Request Exception
should be returned with details of what fields are incorrect (notification pattern). In this project's code examples it's done automatically in DTOs by class-validator library.500 Internal Server Error
, in this case without adding additional info since it may cause a leak of some sensitive data.Application should be protected not only from incorrect user input but from a programmer errors as well by throwing exceptions when something is not used as intended. No details should be returned to the user in case of programmer errors since those details may contain some sensitive information about the program.
By default, in NodeJS Error objects serialize to JSON with output like this:
{
name: 'ValidationError';
}
Consider serializing errors by creating a toJSON()
method so it can be easily sent to other processes as a plain object.
Consider adding optional metadata
object to exceptions (if language doesn't support anything similar by default) and pass some useful technical information about the error when throwing. This will make debugging easier.
Important to keep in mind: never log or add to metadata
any sensitive information (like passwords, emails, phone numbers etc) since this information may leak into log files. Aim adding only technical information.
Example files:
Read more:
Software Testing helps catching bugs early. Properly tested software product ensures reliability, security and high performance which further results in time saving, cost effectiveness and customer satisfaction.
Lets review two types of software testing:
Testing module/use-case internal structures (creating a test for every file/class) is called White Box
testing. White Box testing is widely used technique, but it has disadvantages. It creates coupling to implementation details, so every time you decide to refactor business logic code this may also cause a refactoring of corresponding tests.
To solve this and get the most out of your tests, prefer Black Box
testing (also called Behavioral Testing). This means that tests should focus on testing user-facing behavior users care about (your code's public API, for example createUser()
method in Application Service
), not the implementation details of individual units it has inside. This avoids coupling, protects tests from changes that may happen while refactoring, makes tests easier to understand and maintain thus saving time.
Try to avoid White Box testing when possible. Though, there are cases when White Box testing may be needed, for example:
Use White Box testing only when it is really needed and as an addition to Black Box testing, not the other way around.
It's all about investing only in the tests that yield the biggest return on your effort.
Behavioral tests can be divided in two parts:
Note: some people try to make e2e tests faster by using in-memory or embedded databases (like sqlite3). This makes tests faster, but reduces the reliability of those tests. In real e2e testing this should be avoided. Read more: Don't use In-Memory Databases for Tests.
For projects with a bigger user base you might want to implement some kind of load testing to see how program behaves with a lot of concurrent users. Artillery is a nice tool for that based on NodeJS. Though, there are plenty of other tools to choose from: Top 6 Tools for API & Load Testing.
Example files: // TODO
Read more:
Example files:
process.env
- those are environmental variables..env
and populated with real keys for every environment (local, dev or prod). Don't forget to add .env
to .gitignore file to avoid pushing it to repo and leaking all keys.log
/info
for events that are meaningful during production, debug
for events useful while developing/debugging, and warn
/error
for unwanted behavior on any stage.console.log
). Use mature logger libraries (for example Winston) that support features like enabling/disabling log levels, convenient log formats that are easy to parse (like JSON) etc.Read more:
So instead of using typical layered style when all application is divided into services, controllers etc, we divide everything by modules. Now, how to structure files inside those modules?
A lot of people tend to do the same thing as before: create a separate folders/files for services, controllers etc and keep all module's use-cases logic there, making those controllers and services bloated with responsibilities. This is the same approach that makes navigation harder.
Using this approach, every time something in a service changes, we might have to go to another folder to change controllers, and then go to dtos folder to change the corresponding dto etc.
It would be more logical to separate every module by components and have all the related files close together. Now if a use-case changes, those changes are usually made in a single use-case component, not everywhere across the module.
This is called The Common Closure Principle (CCP). Folder/file structure in this project uses this principle. Related files that usually change together (and are not used by anything else outside of that component) are stored close together, in a single use-case folder. Check user use-cases folder for examples.
And shared files (like domain objects, repositories etc) are stored apart since those are reused by multiple use-cases. Domain layer is isolated, and use-cases which are essentially wrappers around business logic are treated as components. This approach makes navigation and maintaining easier. Check user folder for an example.
The aim here should to be strategic and place classes that we, from experience, know often changes together into the same component.
Keep in mind that this project's folder/file structure is an example and might not work for everyone. Main recommendations here are:
There are different approaches to file/folder structuring, like explicitly separating each layer into a corresponding folder. This defines boundaries more clearly but is harder to navigate. Choose what suits better for the project/personal preference.
Consider giving a descriptive type names to files after a dot ".
", like *.service.ts
or *.entity.ts
. This makes it easier to differentiate what files does what and makes it easier to find those files using fuzzy search (CTRL+P
for Windows/Linux and ⌘+P
for MacOS in VSCode to try it out).
Read more:
Static code analysis is a method of debugging by examining source code before a program is run.
For JavasScript and TypeScript, Eslint with typescript-eslint plugin and some rules (like airbnb) can be a great tool to enforce writing better code.
Using any
type is a bad practice. Consider disallowing it (and other things that may cause problems):
// .eslintrc.js file
rules: {
'@typescript-eslint/no-explicit-any': 'error',
// ...
}
Also, enabling strict mode in tsconfig.json
is recommended:
"compilerOptions": {
"strict": true,
// ...
}
Example file: .eslintrc.js
Code Spell Checker may be a good addition to eslint.
Read more:
The way code looks adds to our understanding of it. Good style makes reading code a pleasurable and consistent experience.
Consider using code formatters like Prettier to maintain same code styles in the project.
Read more:
Create documentation that may help users/other developers to use your program.
Example files:
@ApiProperty()
decorators. This is NestJS Swagger module.@ApiOperation()
and @ApiResponse()
decorators.Read more:
There are a lot of projects out there which take effort to configure after downloading it. Everything has to be set up manually: database, all configs etc. If new developer joins the team he has to waste a lot of time just to make application work.
This is a bad practice and should be avoided. Setting up project after downloading it should be as easy as launching one or few commands in terminal. Consider adding scripts to do this automatically:
To avoid manually creating data in the database, seeding is a great solution to populate database with data for development and testing purposes (e2e testing). Wiki description.
This project uses typeorm-seeding package.
Example file: user.seeds.ts
Migrations are used for database table/schema changes:
Database migration refers to the management of incremental, reversible changes and version control to relational database schemas. A schema migration is performed on a database whenever it is necessary to update or revert that database's schema to some newer or older version.
Source: Wiki
Migrations should be generated every time database table schema is changed. When pushed to production it can be launched automatically.
BE CAREFUL not to drop some columns/tables that contain data by accident. Perform data migrations before table schema migrations and always backup database before doing anything.
This project uses Typeorm Migrations which automatically generates sql table schema migrations like this:
Example file: 1611765824842-CreateTables.ts
Seeds and migrations belong to Infrastructure layer.
By default there is no limit on how many request users can make to your API. This may lead to problems, like DDoS or brute force attacks, lags and performance issues etc.
To solve this, implementing Rate Limiting is essential for any API.
Read more:
Code generation can be important when using complex architectures to avoid typing boilerplate code manually.
Hygen is a great example. This tool can generate building blocks (or entire modules) by using custom templates. Templates can be designed to follow best practices and concepts based on Clean/Hexagonal Architecture, DDD, SOLID etc.
Main advantages of automatic code generation are:
Note:
Consider creating a bunch of shared custom utility types for different situations.
Some examples can be found in types folder.
Consider launching tests/code formatting/linting every time you do git push
or git commit
. This prevents bad code getting in your repo. Husky is a great tool for that.
Read more:
This can be achieved by making class final
.
Note: in TypeScript, unlike other languages, there is no default way to make class final
. But there is a way around it using a custom decorator.
Example file: final.decorator.ts
Read more:
Conventional commits add some useful prefixes to your commit messages, for example:
feat: added ability to delete user's profile
This creates a common language that makes easier communicating the nature of changes to teammates and also may be useful for automatic package versioning and release notes generation.
Read more: