Codetalk is an example application that implements Domain-Driven Design (DDD) and Hexagonal Architecture. It provides the ability to create talks, events, and speakers, and enables speakers to propose talks that can be reviewed by event organizers and approved or rejected.
Prerequisites:
Now:
git clone [email protected]:DanielRamosAcosta/codetalk.git
cd codetalk
yarn install
docker-compose up -d database # starts DDBB
yarn start:dev # opens the server in development mode
You are now good ready to go!! 👯
-
build
: Compiles the project for later usingyarn start
-
initialize:db
: Initializes a local database -
start
: Opens the server by compiling the sources on the fly -
start:dev
: Opens the server compiling the project on the fly in watch mode -
start:prod
: Opens the server in production mode using the compiled sources -
typecheck
: Checks the typing integrity of the project -
lint:check
: Lints all the files -
lint:fix
: Lints and fixes all the files -
test
: Runs all the tests -
test:unitary
: Runs unitary tests -
test:integration
: Runs integration tests that uses local elements (local database, local event bus, etc...) -
test:integration:third-party
: Runs integration tests that uses third party elements (stripe, email services, etc...) -
test:e2e:memory
: Runs E2E tests using the in-memory repositories -
test:e2e:db
: Runs E2E tests using the real database repositories -
precommit
: Runs all the necessary commands that would make the CI pass
We use Docker as a utility tool, mainly for running MongoDB. In the docker-compose.yml
you have two wservuces services:
codetalk
: The API if you want to open it as a docker containerdatabase
: A mongodb database that we use for starting the API in development mode and running the integration tests locally.
- Node
- Nestjs
- Validations with Class Validator & Class Transformer
- OpenAPI docs
- Mainly used as dependency injection container
- TypeScript
- Stripe
The architecture follows the principles from Hexagonal Architecture, and the final implementation is inspired by this and this repositories from CodelyTV.
All the main code of the application lives under src
Under this directory lives all the main application. This root directory contains all the modules of the app, and inside of each module you can find the classic division domain/use-cases/infrastructure
.
- Domain: All the classes needed for modeling the business.
- Use Cases (AKA Application): These are specific use cases which orchestrates several domain elements to perform its job.
- Infrastructure: All the elements that are coupled to a certain Database/Library/Framework.
For example:
.
├── MainModule.ts
├── Token.ts
└── talks
├── TalksModule.ts
├── domain
│ ├── errors
│ │ ├── MaximumCospeakersReachedError.ts
│ │ └── TalkTitleTooLongError.ts
│ ├── Talk.ts
│ └── TalkStatus.ts
├── use-cases
│ ├── ApproveTalk.ts
│ ├── ProposeTalk.ts
│ └── GetTalk.ts
│ └── controllers
└── infrastructure
├── ApproveTalkEndpoint.ts
├── ProposeTalkEndpoint.ts
├── dtos
│ ├── ProposeTalkRequestDTO.ts
│ └── TalkResponseDTO.ts
├── GetTalkEndpoint.ts
└── ReviewTalkEndpoint.ts
Instead of depending on a certain implementation, we depend on an abstraction (an interface). This allows us to create a more decoupled architecture and facilitates testing.
It's the D from the SOLID principles.
You can read more about dependency inversion here.
- Do not import third-parties or side effect methods into the domain/use cases layer
- Instead, create an interface that represent that interaction
- Create two implementations of that interface:
- A "real" implementation (calling TypeORM, Stripe, Fetch HTTP API Call...).
- A "fake" implementation just for testing purposes.
For wiring up all the dependencies, we are using the native Nestjs dependency container. This is the only thing that we are coupled to, specially from the application layer.
A special thing that we have to take into account, is when injecting interfaces.
The interfaces are a compile-time thing of Typescript, so when we need to inject a certain implementation we need to specify an identifier for that interface with a token.
// TalkRepository.interface.ts
export interface TalkRepository {
save(talk: Talk): Promise<void>
findBy(talkId: TalkId): Promise<Talk | undefined>
}
// Token.ts
export enum Token {
TALK_REPOSITORY = 'TALK_REPOSITORY',
// ...
}
Then we need to specify the dependency in the class consuming this interface
class PurposeTalk {
constructor(@Inject(Token.TALK_REPOSITORY) private readonly talkRepository: TalkRepository) {}
}
Later on, we need to wire up these dependencies from a Nestjs module:
// TalkRepositoryModule.ts
@Global()
@Module({
providers: [{ provide: Token.TALK_REPOSITORY, useClass: TalkRepositoryMongo }],
exports: [Token.TALK_REPOSITORY],
})
export class TalkRepositoryModule {}
We are using Inheritance for specifying which element of Hexagonal Architecture is each class.
For example, we are extending a ValueObject
class when having a value object or a UseCase
for the use cases.
In general, this inheritance does not have any logic. It's just like a explanatory variable.
Examples:
class ReservationLister extends UseCase { /* ... */}
class Reservation extends AggregateRoot { /* ... */ }
class ReservationTitle extends ValueObject<string> { /* ... */ }
- We are using Jest and tepper for acceptance tests.
- Unitary Tests are paired with the element that tests. For example
Talk.spec.ts
is next toTalk.ts
. - Acceptance tests lives under the
tests
directory. These tests crosses the framework layers and we interact with the API as a black box. These are the main tests that we use for TDD by performing outside-in.
- The CI and CD are in Github Actions
- We run the precommit script before each commit using Husky.
- The CI runs for both acceptance/unitary and integration tests with a real database.
- After all tests passed, then the API is re-deployed
The project was mainly developed by Alberto González and Daniel Ramos from Acid Tango with ❤️ and 💪