├── apps ├── .gitkeep └── library │ ├── src │ ├── app │ │ ├── .gitkeep │ │ ├── app.module.ts │ │ └── app-core.module.ts │ ├── assets │ │ └── .gitkeep │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── main.ts │ └── migrations │ │ ├── Migration20221204160713.ts │ │ └── .snapshot-library.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ ├── tsconfig.app.json │ ├── jest.config.js │ ├── .eslintrc.json │ ├── project.json │ └── test │ └── smoke │ ├── cancel-hold.spec.ts │ └── take-book-on-hold.spec.ts ├── libs ├── .gitkeep ├── lending │ ├── infrastructure │ │ ├── src │ │ │ ├── index.ts │ │ │ └── lib │ │ │ │ ├── lending-infrastructure.module.ts │ │ │ │ ├── lending.module.ts │ │ │ │ └── typeorm │ │ │ │ ├── lending-typeorm.module.ts │ │ │ │ ├── entities │ │ │ │ ├── hold.entity.ts │ │ │ │ ├── book.entity.ts │ │ │ │ └── patron.entity.ts │ │ │ │ └── repositories │ │ │ │ ├── patron.repository.ts │ │ │ │ ├── book.repository.ts │ │ │ │ └── patron.repository.spec.ts │ │ ├── .babelrc │ │ ├── package.json │ │ ├── README.md │ │ ├── .eslintrc.json │ │ ├── tsconfig.json │ │ ├── tsconfig.spec.json │ │ ├── jest.config.js │ │ ├── tsconfig.lib.json │ │ └── project.json │ ├── ui-rest │ │ ├── src │ │ │ ├── index.ts │ │ │ └── lib │ │ │ │ ├── lending-ui-rest.module.ts │ │ │ │ └── patron-profile │ │ │ │ ├── dtos │ │ │ │ └── place-on-hold.dto.ts │ │ │ │ ├── patron-profile.module.ts │ │ │ │ └── patron-profile.controller.ts │ │ ├── .babelrc │ │ ├── README.md │ │ ├── .eslintrc.json │ │ ├── jest.config.js │ │ ├── tsconfig.spec.json │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ └── project.json │ ├── domain │ │ ├── .babelrc │ │ ├── package.json │ │ ├── src │ │ │ ├── lib │ │ │ │ ├── events │ │ │ │ │ ├── maximum-number-on-holds-reached.ts │ │ │ │ │ ├── patron-event.ts │ │ │ │ │ ├── book-checked-out.ts │ │ │ │ │ ├── book-hold-canceling-failed.ts │ │ │ │ │ ├── book-duplicate-hold-found.event.ts │ │ │ │ │ ├── book-hold-canceled.ts │ │ │ │ │ ├── book-hold-failed.ts │ │ │ │ │ ├── book-placed-on-hold.ts │ │ │ │ │ ├── book-check-out-failed.ts │ │ │ │ │ └── book-placed-on-hold-events.ts │ │ │ │ ├── value-objects │ │ │ │ │ ├── patron-type.ts │ │ │ │ │ ├── book-id.ts │ │ │ │ │ ├── patron-id.ts │ │ │ │ │ ├── library-branch-id.ts │ │ │ │ │ ├── patron-information.ts │ │ │ │ │ ├── date.vo.ts │ │ │ │ │ ├── number-of-days.ts │ │ │ │ │ ├── hold.ts │ │ │ │ │ ├── patron-holds.ts │ │ │ │ │ └── hold-duration.ts │ │ │ │ ├── book │ │ │ │ │ ├── book.ts │ │ │ │ │ ├── available-book.ts │ │ │ │ │ └── book-on-hold.ts │ │ │ │ ├── factories │ │ │ │ │ └── patron.factory.ts │ │ │ │ ├── policies │ │ │ │ │ └── placing-on-hold-policy.ts │ │ │ │ └── patron.ts │ │ │ └── index.ts │ │ ├── README.md │ │ ├── .eslintrc.json │ │ ├── jest.config.js │ │ ├── tsconfig.json │ │ ├── tsconfig.spec.json │ │ ├── tsconfig.lib.json │ │ ├── tests │ │ │ ├── book.fixtures.ts │ │ │ ├── patron-canceling-hold.spec.ts │ │ │ ├── patron-requestion-last-passible-hold.spec.ts │ │ │ ├── patron-requesting-open-ended-hold.spec.ts │ │ │ ├── patron.fixtures.ts │ │ │ └── patron-requesting-close-ended-hold.spec.ts │ │ └── project.json │ └── application │ │ ├── .babelrc │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ ├── lib │ │ │ ├── ports │ │ │ │ ├── book.repository.ts │ │ │ │ └── patron.repository.ts │ │ │ ├── place-on-hold │ │ │ │ ├── find-available-book.ts │ │ │ │ ├── place-on-hold.command.ts │ │ │ │ ├── place-on-hold.handler.ts │ │ │ │ └── place-on-hold.handler.spec.ts │ │ │ ├── cancel-hold │ │ │ │ ├── find-book-on-hold.ts │ │ │ │ ├── cancel-hold.command.ts │ │ │ │ ├── cancel-hold.handler.ts │ │ │ │ └── cancel-hold.handler.spec.ts │ │ │ ├── check-out │ │ │ │ ├── check-out-book.command.ts │ │ │ │ ├── check-out-book.handler.ts │ │ │ │ └── check-out-book.handler.spec.ts │ │ │ ├── lending.facade.ts │ │ │ ├── duplicate-hold.event.handler.ts │ │ │ ├── create-available-book-on-instance-added.event-handler.ts │ │ │ ├── book-hold-canceled.event-handler.ts │ │ │ ├── lending-application.module.ts │ │ │ └── book-placed-on-hold.event-handler.ts │ │ └── index.ts │ │ ├── .eslintrc.json │ │ ├── tsconfig.json │ │ ├── tsconfig.spec.json │ │ ├── jest.config.js │ │ ├── tsconfig.lib.json │ │ └── project.json ├── catalogue │ ├── .babelrc │ ├── package.json │ ├── src │ │ ├── lib │ │ │ ├── book-type.ts │ │ │ ├── book-id.ts │ │ │ ├── dto │ │ │ │ ├── update-book.dto.ts │ │ │ │ └── create-book.dto.ts │ │ │ ├── isbn.ts │ │ │ ├── isbn.spec.ts │ │ │ ├── catalogue.module.ts │ │ │ ├── book-instance-added-to-catalogue.ts │ │ │ ├── book-instance.ts │ │ │ ├── catalogue-database.ts │ │ │ ├── book.controller.ts │ │ │ ├── book.ts │ │ │ ├── custom-db-types.ts │ │ │ └── catalogue.ts │ │ └── index.ts │ ├── README.md │ ├── .eslintrc.json │ ├── jest.config.js │ ├── tsconfig.json │ ├── tsconfig.spec.json │ ├── tsconfig.lib.json │ └── project.json └── shared │ ├── domain │ ├── .babelrc │ ├── package.json │ ├── src │ │ ├── lib │ │ │ ├── commands │ │ │ │ └── result.ts │ │ │ ├── aggregates │ │ │ │ ├── aggregate-root-is-stale.ts │ │ │ │ └── version.ts │ │ │ ├── errors │ │ │ │ └── invalid-argument.exception.ts │ │ │ ├── events │ │ │ │ ├── domain.event.ts │ │ │ │ └── domain-events.ts │ │ │ └── uuid.ts │ │ └── index.ts │ ├── README.md │ ├── tsconfig.lib.json │ ├── .eslintrc.json │ ├── jest.config.js │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── project.json │ └── infrastructure-nestjs-cqrs-events │ ├── .babelrc │ ├── src │ ├── index.ts │ └── lib │ │ ├── nestjs-cqrs-domain-events.ts │ │ └── shared-infrastructure-nestjs-cqrs-events.module.ts │ ├── package.json │ ├── README.md │ ├── .eslintrc.json │ ├── tsconfig.spec.json │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── jest.config.js │ └── project.json ├── tools ├── generators │ └── .gitkeep └── tsconfig.tools.json ├── .prettierrc ├── .env ├── .prettierignore ├── jest.preset.js ├── docs ├── images │ ├── em │ │ ├── holding.png │ │ ├── checking-out.png │ │ ├── canceling-hold.png │ │ ├── expiring-hold.png │ │ ├── overdue-checkouts.png │ │ └── adding-to-catalogue.png │ ├── example-mapping.png │ ├── placing_on_hold.jpg │ ├── aggregates │ │ ├── agg-1.png │ │ ├── agg-2.png │ │ └── agg-3.png │ ├── dl │ │ ├── holding │ │ │ ├── example-1.png │ │ │ ├── example-2.png │ │ │ ├── example-3.png │ │ │ ├── example-4.png │ │ │ ├── example-5.png │ │ │ ├── example-6.png │ │ │ ├── example-7.png │ │ │ ├── example-8.png │ │ │ ├── example-9.png │ │ │ ├── example-10.png │ │ │ ├── example-11.png │ │ │ ├── example-12.png │ │ │ └── example-13.png │ │ ├── expiringhold │ │ │ ├── example-1.png │ │ │ ├── example-2.png │ │ │ └── example-3.png │ │ ├── bookcheckouts │ │ │ ├── example-1.png │ │ │ ├── example-2.png │ │ │ ├── example-3.png │ │ │ ├── example-4.png │ │ │ └── example-5.png │ │ ├── cancelinghold │ │ │ ├── example-1.png │ │ │ ├── example-2.png │ │ │ ├── example-3.png │ │ │ ├── example-4.png │ │ │ └── example-5.png │ │ ├── addingtocatalogue │ │ │ ├── example-1.png │ │ │ └── example-2.png │ │ └── overduecheckouts │ │ │ ├── example-1.png │ │ │ └── example-2.png │ ├── architecture-big-picture.png │ ├── eventstorming-big-picture.jpg │ ├── eventstorming-definitions.png │ ├── eventstorming-domain-desc.png │ ├── es │ │ └── bigpicture │ │ │ ├── definitions-1.png │ │ │ ├── definitions-2.png │ │ │ ├── book-catalogue.png │ │ │ ├── book-catalogue-definitions.png │ │ │ ├── open-ended-holding-process.png │ │ │ ├── the-book-returning-process.png │ │ │ └── close-ended-holding-process.png │ ├── eventstorming-design-level.jpg │ ├── placing-on-hold-policy-max.png │ ├── placing-on-hold-policy-overdue.png │ ├── placing-on-hold-policy-open-ended.png │ └── placing-on-hold-policy-restricted.png ├── c4 │ └── component-diagram.png ├── example-mapping.md ├── big-picture.md └── design-level.md ├── jest.config.js ├── .vscode └── extensions.json ├── .editorconfig ├── docker-compose.yml ├── workspace.json ├── .gitignore ├── nx.json ├── .eslintrc.json ├── LICENSE ├── tsconfig.base.json ├── package.json └── README.md /apps/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /libs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tools/generators/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/library/src/app/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/library/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/lending.module'; 2 | -------------------------------------------------------------------------------- /libs/lending/ui-rest/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/lending-ui-rest.module'; 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DB_HOST=localhost 2 | DB_NAME=library 3 | DB_PASSWORD=mysecretpassword 4 | DB_USER=lib -------------------------------------------------------------------------------- /libs/catalogue/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/catalogue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@library/catalogue", 3 | "version": "0.0.1" 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | /dist 4 | /coverage 5 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nrwl/jest/preset'); 2 | 3 | module.exports = { ...nxPreset }; 4 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/book-type.ts: -------------------------------------------------------------------------------- 1 | export enum BookType { 2 | Restricted, 3 | Circulating, 4 | } 5 | -------------------------------------------------------------------------------- /libs/lending/domain/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/lending/ui-rest/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/shared/domain/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/shared/domain/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@library/shared/domain", 3 | "version": "0.0.1" 4 | } 5 | -------------------------------------------------------------------------------- /libs/shared/domain/src/lib/commands/result.ts: -------------------------------------------------------------------------------- 1 | export enum Result { 2 | Success, 3 | Rejection, 4 | } 5 | -------------------------------------------------------------------------------- /apps/library/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /apps/library/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | }; 4 | -------------------------------------------------------------------------------- /libs/lending/application/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/lending/domain/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@library/lending/domain", 3 | "version": "0.0.1" 4 | } 5 | -------------------------------------------------------------------------------- /docs/images/em/holding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/em/holding.png -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/events/maximum-number-on-holds-reached.ts: -------------------------------------------------------------------------------- 1 | export class MaximumNumberOhHoldsReached {} 2 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/lending/application/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@library/lending/application", 3 | "version": "0.0.1" 4 | } 5 | -------------------------------------------------------------------------------- /libs/shared/domain/src/lib/aggregates/aggregate-root-is-stale.ts: -------------------------------------------------------------------------------- 1 | export class AggregateRootIsStale extends Error {} 2 | -------------------------------------------------------------------------------- /libs/shared/domain/src/lib/errors/invalid-argument.exception.ts: -------------------------------------------------------------------------------- 1 | export class InvalidArgumentException extends Error {} 2 | -------------------------------------------------------------------------------- /docs/c4/component-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/c4/component-diagram.png -------------------------------------------------------------------------------- /docs/images/em/checking-out.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/em/checking-out.png -------------------------------------------------------------------------------- /docs/images/example-mapping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/example-mapping.png -------------------------------------------------------------------------------- /docs/images/placing_on_hold.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/placing_on_hold.jpg -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/value-objects/patron-type.ts: -------------------------------------------------------------------------------- 1 | export enum PatronType { 2 | Regular, 3 | Researcher, 4 | } 5 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@library/lending/infrastructure", 3 | "version": "0.0.1" 4 | } 5 | -------------------------------------------------------------------------------- /docs/images/aggregates/agg-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/aggregates/agg-1.png -------------------------------------------------------------------------------- /docs/images/aggregates/agg-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/aggregates/agg-2.png -------------------------------------------------------------------------------- /docs/images/aggregates/agg-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/aggregates/agg-3.png -------------------------------------------------------------------------------- /docs/images/em/canceling-hold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/em/canceling-hold.png -------------------------------------------------------------------------------- /docs/images/em/expiring-hold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/em/expiring-hold.png -------------------------------------------------------------------------------- /libs/catalogue/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/book-instance-added-to-catalogue'; 2 | export * from './lib/catalogue.module'; 3 | -------------------------------------------------------------------------------- /libs/shared/infrastructure-nestjs-cqrs-events/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@nrwl/web/babel", { "useBuiltIns": "usage" }]] 3 | } 4 | -------------------------------------------------------------------------------- /libs/shared/infrastructure-nestjs-cqrs-events/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/shared-infrastructure-nestjs-cqrs-events.module'; 2 | -------------------------------------------------------------------------------- /docs/images/dl/holding/example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-1.png -------------------------------------------------------------------------------- /docs/images/dl/holding/example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-2.png -------------------------------------------------------------------------------- /docs/images/dl/holding/example-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-3.png -------------------------------------------------------------------------------- /docs/images/dl/holding/example-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-4.png -------------------------------------------------------------------------------- /docs/images/dl/holding/example-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-5.png -------------------------------------------------------------------------------- /docs/images/dl/holding/example-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-6.png -------------------------------------------------------------------------------- /docs/images/dl/holding/example-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-7.png -------------------------------------------------------------------------------- /docs/images/dl/holding/example-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-8.png -------------------------------------------------------------------------------- /docs/images/dl/holding/example-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-9.png -------------------------------------------------------------------------------- /docs/images/em/overdue-checkouts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/em/overdue-checkouts.png -------------------------------------------------------------------------------- /docs/images/dl/holding/example-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-10.png -------------------------------------------------------------------------------- /docs/images/dl/holding/example-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-11.png -------------------------------------------------------------------------------- /docs/images/dl/holding/example-12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-12.png -------------------------------------------------------------------------------- /docs/images/dl/holding/example-13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/holding/example-13.png -------------------------------------------------------------------------------- /docs/images/em/adding-to-catalogue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/em/adding-to-catalogue.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { getJestProjects } = require('@nrwl/jest'); 2 | 3 | module.exports = { 4 | projects: getJestProjects(), 5 | }; 6 | -------------------------------------------------------------------------------- /docs/images/architecture-big-picture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/architecture-big-picture.png -------------------------------------------------------------------------------- /docs/images/dl/expiringhold/example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/expiringhold/example-1.png -------------------------------------------------------------------------------- /docs/images/dl/expiringhold/example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/expiringhold/example-2.png -------------------------------------------------------------------------------- /docs/images/dl/expiringhold/example-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/expiringhold/example-3.png -------------------------------------------------------------------------------- /docs/images/eventstorming-big-picture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/eventstorming-big-picture.jpg -------------------------------------------------------------------------------- /docs/images/eventstorming-definitions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/eventstorming-definitions.png -------------------------------------------------------------------------------- /docs/images/eventstorming-domain-desc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/eventstorming-domain-desc.png -------------------------------------------------------------------------------- /docs/images/dl/bookcheckouts/example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/bookcheckouts/example-1.png -------------------------------------------------------------------------------- /docs/images/dl/bookcheckouts/example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/bookcheckouts/example-2.png -------------------------------------------------------------------------------- /docs/images/dl/bookcheckouts/example-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/bookcheckouts/example-3.png -------------------------------------------------------------------------------- /docs/images/dl/bookcheckouts/example-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/bookcheckouts/example-4.png -------------------------------------------------------------------------------- /docs/images/dl/bookcheckouts/example-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/bookcheckouts/example-5.png -------------------------------------------------------------------------------- /docs/images/dl/cancelinghold/example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/cancelinghold/example-1.png -------------------------------------------------------------------------------- /docs/images/dl/cancelinghold/example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/cancelinghold/example-2.png -------------------------------------------------------------------------------- /docs/images/dl/cancelinghold/example-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/cancelinghold/example-3.png -------------------------------------------------------------------------------- /docs/images/dl/cancelinghold/example-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/cancelinghold/example-4.png -------------------------------------------------------------------------------- /docs/images/dl/cancelinghold/example-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/cancelinghold/example-5.png -------------------------------------------------------------------------------- /docs/images/es/bigpicture/definitions-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/es/bigpicture/definitions-1.png -------------------------------------------------------------------------------- /docs/images/es/bigpicture/definitions-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/es/bigpicture/definitions-2.png -------------------------------------------------------------------------------- /docs/images/eventstorming-design-level.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/eventstorming-design-level.jpg -------------------------------------------------------------------------------- /docs/images/placing-on-hold-policy-max.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/placing-on-hold-policy-max.png -------------------------------------------------------------------------------- /docs/images/dl/addingtocatalogue/example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/addingtocatalogue/example-1.png -------------------------------------------------------------------------------- /docs/images/dl/addingtocatalogue/example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/addingtocatalogue/example-2.png -------------------------------------------------------------------------------- /docs/images/dl/overduecheckouts/example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/overduecheckouts/example-1.png -------------------------------------------------------------------------------- /docs/images/dl/overduecheckouts/example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/dl/overduecheckouts/example-2.png -------------------------------------------------------------------------------- /docs/images/es/bigpicture/book-catalogue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/es/bigpicture/book-catalogue.png -------------------------------------------------------------------------------- /docs/images/placing-on-hold-policy-overdue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/placing-on-hold-policy-overdue.png -------------------------------------------------------------------------------- /libs/shared/domain/src/lib/events/domain.event.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 2 | export interface DomainEvent {} 3 | -------------------------------------------------------------------------------- /libs/shared/infrastructure-nestjs-cqrs-events/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@library/shared/infrastructure-nestjs-cqrs-events", 3 | "version": "0.0.1" 4 | } 5 | -------------------------------------------------------------------------------- /docs/images/placing-on-hold-policy-open-ended.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/placing-on-hold-policy-open-ended.png -------------------------------------------------------------------------------- /docs/images/placing-on-hold-policy-restricted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/placing-on-hold-policy-restricted.png -------------------------------------------------------------------------------- /docs/images/es/bigpicture/book-catalogue-definitions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/es/bigpicture/book-catalogue-definitions.png -------------------------------------------------------------------------------- /docs/images/es/bigpicture/open-ended-holding-process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/es/bigpicture/open-ended-holding-process.png -------------------------------------------------------------------------------- /docs/images/es/bigpicture/the-book-returning-process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/es/bigpicture/the-book-returning-process.png -------------------------------------------------------------------------------- /docs/images/es/bigpicture/close-ended-holding-process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddd-by-examples/library-nestjs/HEAD/docs/images/es/bigpicture/close-ended-holding-process.png -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/events/patron-event.ts: -------------------------------------------------------------------------------- 1 | import { PatronId } from '../value-objects/patron-id'; 2 | 3 | export interface PatronEvent { 4 | patronId: PatronId; 5 | } 6 | -------------------------------------------------------------------------------- /libs/shared/domain/src/lib/events/domain-events.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from './domain.event'; 2 | 3 | export abstract class DomainEvents { 4 | abstract publish(event: DomainEvent): void; 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nrwl.angular-console", 4 | "esbenp.prettier-vscode", 5 | "firsttris.vscode-jest-runner", 6 | "dbaeumer.vscode-eslint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/book-id.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@library/shared/domain'; 2 | 3 | export class BookId extends Uuid { 4 | static generate(): BookId { 5 | return super.generate(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /libs/catalogue/README.md: -------------------------------------------------------------------------------- 1 | # catalogue 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test catalogue` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/value-objects/book-id.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@library/shared/domain'; 2 | 3 | export class BookId extends Uuid { 4 | static generate(): BookId { 5 | return super.generate(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/dto/update-book.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateBookDto } from './create-book.dto'; 3 | 4 | export class UpdateBookDto extends PartialType(CreateBookDto) {} 5 | -------------------------------------------------------------------------------- /libs/shared/domain/src/lib/aggregates/version.ts: -------------------------------------------------------------------------------- 1 | import { TinyTypeOf } from 'tiny-types'; 2 | 3 | export class Version extends TinyTypeOf() { 4 | static zero(): Version { 5 | return new Version(0); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/book/book.ts: -------------------------------------------------------------------------------- 1 | import { Version } from '@library/shared/domain'; 2 | import { BookId } from '../value-objects/book-id'; 3 | 4 | export interface Book { 5 | bookId: BookId; 6 | version: Version; 7 | } 8 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/value-objects/patron-id.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@library/shared/domain'; 2 | 3 | export class PatronId extends Uuid { 4 | static generate(): PatronId { 5 | return super.generate(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /libs/shared/domain/README.md: -------------------------------------------------------------------------------- 1 | # shared-domain 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test shared-domain` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/lending/domain/README.md: -------------------------------------------------------------------------------- 1 | # lending-domain 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test lending-domain` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/lending/ui-rest/README.md: -------------------------------------------------------------------------------- 1 | # lending-ui-rest 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test lending-ui-rest` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/lending/application/README.md: -------------------------------------------------------------------------------- 1 | # lending-application 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test lending-application` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/value-objects/library-branch-id.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@library/shared/domain'; 2 | 3 | export class LibraryBranchId extends Uuid { 4 | static generate(): LibraryBranchId { 5 | return super.generate(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/README.md: -------------------------------------------------------------------------------- 1 | # lending-infrastructure 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test lending-infrastructure` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/shared/domain/src/lib/uuid.ts: -------------------------------------------------------------------------------- 1 | import { TinyTypeOf } from 'tiny-types'; 2 | import { randomUUID } from 'crypto'; 3 | 4 | export class Uuid extends TinyTypeOf() { 5 | static generate(): Uuid { 6 | return new Uuid(randomUUID()); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/events/book-checked-out.ts: -------------------------------------------------------------------------------- 1 | import { PatronId } from '../value-objects/patron-id'; 2 | import { PatronEvent } from './patron-event'; 3 | 4 | export class BookCheckedOut implements PatronEvent { 5 | constructor(public readonly patronId: PatronId) {} 6 | } 7 | -------------------------------------------------------------------------------- /libs/lending/ui-rest/src/lib/lending-ui-rest.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PatronProfileModule } from './patron-profile/patron-profile.module'; 3 | 4 | @Module({ 5 | imports: [PatronProfileModule], 6 | }) 7 | export class LendingUiRestModule {} 8 | -------------------------------------------------------------------------------- /apps/library/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.app.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /apps/library/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /libs/lending/ui-rest/src/lib/patron-profile/dtos/place-on-hold.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsUUID, Min } from 'class-validator'; 2 | 3 | export class PlaceOnHoldDto { 4 | @IsUUID() 5 | bookId!: string; 6 | 7 | @IsNumber() 8 | @Min(1) 9 | numberOfDays!: number; 10 | } 11 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/events/book-hold-canceling-failed.ts: -------------------------------------------------------------------------------- 1 | import { PatronId } from '../value-objects/patron-id'; 2 | import { PatronEvent } from './patron-event'; 3 | 4 | export class BookHoldCancelingFailed implements PatronEvent { 5 | constructor(public readonly patronId: PatronId) {} 6 | } 7 | -------------------------------------------------------------------------------- /libs/shared/infrastructure-nestjs-cqrs-events/README.md: -------------------------------------------------------------------------------- 1 | # shared-infrastructure-nestjs-cqrs-events 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test shared-infrastructure-nestjs-cqrs-events` to execute the unit tests via [Jest](https://jestjs.io). 8 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/ports/book.repository.ts: -------------------------------------------------------------------------------- 1 | import { Book, BookId } from '@library/lending/domain'; 2 | import { Option } from 'fp-ts/Option'; 3 | 4 | export abstract class BookRepository { 5 | abstract findById(id: BookId): Promise>; 6 | abstract save(book: Book): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/place-on-hold/find-available-book.ts: -------------------------------------------------------------------------------- 1 | import { AvailableBook, BookId } from '@library/lending/domain'; 2 | import { Option } from 'fp-ts/lib/Option'; 3 | 4 | export abstract class FindAvailableBook { 5 | abstract findAvailableBookById(id: BookId): Promise>; 6 | } 7 | -------------------------------------------------------------------------------- /tools/tsconfig.tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/out-tsc/tools", 5 | "rootDir": ".", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": ["node"], 9 | "importHelpers": false 10 | }, 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/src/lib/lending-infrastructure.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LendingTypeOrmModule } from './typeorm/lending-typeorm.module'; 3 | 4 | @Module({ 5 | imports: [LendingTypeOrmModule], 6 | exports: [LendingTypeOrmModule], 7 | }) 8 | export class LendingInfrastructureModule {} 9 | -------------------------------------------------------------------------------- /libs/shared/domain/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"] 8 | }, 9 | "exclude": ["**/*.spec.ts", "**/*.test.ts"], 10 | "include": ["**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | db: 4 | image: postgres:12.2 5 | volumes: 6 | - db:/var/lib/postgresql/data 7 | environment: 8 | - POSTGRES_PASSWORD=${DB_PASSWORD} 9 | - POSTGRES_USER=${DB_USER} 10 | - POSTGRES_DB=${DB_NAME} 11 | ports: 12 | - 5432:5432 13 | 14 | volumes: 15 | db: 16 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/cancel-hold/find-book-on-hold.ts: -------------------------------------------------------------------------------- 1 | import { BookId, BookOnHold, PatronId } from '@library/lending/domain'; 2 | import { Option } from 'fp-ts/Option'; 3 | 4 | export abstract class FindBookOnHold { 5 | abstract findBookOnHold( 6 | bookId: BookId, 7 | patronId: PatronId 8 | ): Promise>; 9 | } 10 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/ports/patron.repository.ts: -------------------------------------------------------------------------------- 1 | import { Patron, PatronEvent, PatronId } from '@library/lending/domain'; 2 | import { Option } from 'fp-ts/lib/Option'; 3 | 4 | export abstract class PatronRepository { 5 | abstract publish(event: PatronEvent): Promise; 6 | abstract findById(id: PatronId): Promise>; 7 | } 8 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/events/book-duplicate-hold-found.event.ts: -------------------------------------------------------------------------------- 1 | import { BookId } from '../value-objects/book-id'; 2 | import { PatronId } from '../value-objects/patron-id'; 3 | 4 | export class BookDuplicateHoldFound { 5 | constructor( 6 | public readonly bookId: BookId, 7 | public readonly secondPatronId: PatronId 8 | ) {} 9 | } 10 | -------------------------------------------------------------------------------- /apps/library/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["node"], 7 | "emitDecoratorMetadata": true, 8 | "target": "es2015" 9 | }, 10 | "exclude": ["**/*.spec.ts", "**/*.test.ts"], 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /libs/shared/domain/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/aggregates/aggregate-root-is-stale'; 2 | export * from './lib/aggregates/version'; 3 | export * from './lib/commands/result'; 4 | export * from './lib/errors/invalid-argument.exception'; 5 | export * from './lib/events/domain-events'; 6 | export * from './lib/events/domain.event'; 7 | export * from './lib/uuid'; 8 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/dto/create-book.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsEnum } from 'class-validator'; 2 | import { BookType } from '../book-type'; 3 | 4 | export class CreateBookDto { 5 | @IsString() 6 | author!: string; 7 | @IsString() 8 | title!: string; 9 | @IsString() 10 | isbn!: string; 11 | @IsEnum(BookType) 12 | bookType!: BookType; 13 | } 14 | -------------------------------------------------------------------------------- /apps/library/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { CatalogueModule } from '@library/catalogue'; 2 | import { LendingUiRestModule } from '@library/lending/ui-rest'; 3 | import { Module } from '@nestjs/common'; 4 | import { AppCoreModule } from './app-core.module'; 5 | 6 | @Module({ 7 | imports: [AppCoreModule, CatalogueModule, LendingUiRestModule], 8 | }) 9 | export class AppModule {} 10 | -------------------------------------------------------------------------------- /libs/lending/ui-rest/src/lib/patron-profile/patron-profile.module.ts: -------------------------------------------------------------------------------- 1 | import { LendingModule } from '@library/lending/infrastructure'; 2 | import { Module } from '@nestjs/common'; 3 | import { PatronProfileController } from './patron-profile.controller'; 4 | 5 | @Module({ 6 | imports: [LendingModule], 7 | controllers: [PatronProfileController], 8 | }) 9 | export class PatronProfileModule {} 10 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/value-objects/patron-information.ts: -------------------------------------------------------------------------------- 1 | import { PatronId } from './patron-id'; 2 | import { PatronType } from './patron-type'; 3 | 4 | export class PatronInformation { 5 | constructor( 6 | public readonly patronId: PatronId, 7 | public readonly type: PatronType 8 | ) {} 9 | 10 | isRegular(): boolean { 11 | return this.type === PatronType.Regular; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /libs/catalogue/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/value-objects/date.vo.ts: -------------------------------------------------------------------------------- 1 | import { TinyTypeOf } from 'tiny-types'; 2 | 3 | export class DateVO extends TinyTypeOf() { 4 | static now(): DateVO { 5 | return new DateVO(new Date()); 6 | } 7 | 8 | addDays(days: number): DateVO { 9 | const next = new Date(this.value.getTime()); 10 | next.setDate(next.getDate() + days); 11 | return new DateVO(next); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /libs/shared/domain/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/lending/domain/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/value-objects/number-of-days.ts: -------------------------------------------------------------------------------- 1 | import { ensure, isGreaterThan, TinyTypeOf } from 'tiny-types'; 2 | 3 | export class NumberOfDays extends TinyTypeOf() { 4 | private constructor(days: number) { 5 | super(days); 6 | ensure('NumberOfDays', days, isGreaterThan(0)); 7 | } 8 | 9 | static of(days: number): NumberOfDays { 10 | return new NumberOfDays(days); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /libs/lending/ui-rest/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/lending/application/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/library/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'library', 3 | preset: '../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.spec.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]s$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'js', 'html'], 14 | coverageDirectory: '../../coverage/apps/library', 15 | }; 16 | -------------------------------------------------------------------------------- /libs/shared/infrastructure-nestjs-cqrs-events/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /libs/catalogue/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'catalogue', 3 | preset: '../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.spec.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../coverage/libs/catalogue', 15 | }; 16 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/cancel-hold/cancel-hold.command.ts: -------------------------------------------------------------------------------- 1 | import { BookId, PatronId } from '@library/lending/domain'; 2 | import { Result } from '@library/shared/domain'; 3 | import { Command } from '@nestjs-architects/typed-cqrs'; 4 | 5 | export class CancelHoldCommand extends Command { 6 | constructor( 7 | public readonly patronId: PatronId, 8 | public readonly bookId: BookId 9 | ) { 10 | super(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /libs/lending/application/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/cancel-hold/cancel-hold.command'; 2 | export * from './lib/cancel-hold/find-book-on-hold'; 3 | export * from './lib/lending-application.module'; 4 | export * from './lib/lending.facade'; 5 | export * from './lib/place-on-hold/find-available-book'; 6 | export * from './lib/place-on-hold/place-on-hold.command'; 7 | export * from './lib/ports/book.repository'; 8 | export * from './lib/ports/patron.repository'; 9 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/check-out/check-out-book.command.ts: -------------------------------------------------------------------------------- 1 | import { BookId, PatronId } from '@library/lending/domain'; 2 | import { Result } from '@library/shared/domain'; 3 | import { Command } from '@nestjs-architects/typed-cqrs'; 4 | 5 | export class CheckOutBookCommand extends Command { 6 | constructor( 7 | public readonly patronId: PatronId, 8 | public readonly bookId: BookId 9 | ) { 10 | super(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /libs/shared/domain/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'shared-domain', 3 | preset: '../../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.spec.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../../coverage/libs/shared/domain', 15 | }; 16 | -------------------------------------------------------------------------------- /libs/catalogue/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ], 13 | "compilerOptions": { 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/catalogue/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.spec.ts", 11 | "**/*.test.tsx", 12 | "**/*.spec.tsx", 13 | "**/*.test.js", 14 | "**/*.spec.js", 15 | "**/*.test.jsx", 16 | "**/*.spec.jsx", 17 | "**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /libs/lending/domain/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'lending-domain', 3 | preset: '../../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.spec.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../../coverage/libs/lending/domain', 15 | }; 16 | -------------------------------------------------------------------------------- /libs/lending/domain/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ], 13 | "compilerOptions": { 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/lending/domain/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.spec.ts", 11 | "**/*.test.tsx", 12 | "**/*.spec.tsx", 13 | "**/*.test.js", 14 | "**/*.spec.js", 15 | "**/*.test.jsx", 16 | "**/*.spec.jsx", 17 | "**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /libs/lending/ui-rest/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'lending-ui-rest', 3 | preset: '../../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.spec.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../../coverage/libs/lending/ui-rest', 15 | }; 16 | -------------------------------------------------------------------------------- /libs/lending/ui-rest/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.spec.ts", 11 | "**/*.test.tsx", 12 | "**/*.spec.tsx", 13 | "**/*.test.js", 14 | "**/*.spec.js", 15 | "**/*.test.jsx", 16 | "**/*.spec.jsx", 17 | "**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /libs/shared/domain/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ], 13 | "compilerOptions": { 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/shared/domain/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.spec.ts", 11 | "**/*.test.tsx", 12 | "**/*.spec.tsx", 13 | "**/*.test.js", 14 | "**/*.spec.js", 15 | "**/*.test.jsx", 16 | "**/*.spec.jsx", 17 | "**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/isbn.ts: -------------------------------------------------------------------------------- 1 | import { ensure, isString, matches, TinyTypeOf } from 'tiny-types'; 2 | 3 | export class ISBN extends TinyTypeOf() { 4 | private static readonly VERY_SIMPLE_ISBN_CHECK = new RegExp( 5 | '^\\d{9}[\\d|X]$' 6 | ); 7 | 8 | constructor(isbn: string) { 9 | super(isbn.trim()); 10 | ensure( 11 | 'ISBN', 12 | isbn.trim(), 13 | isString(), 14 | matches(ISBN.VERY_SIMPLE_ISBN_CHECK) 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /libs/lending/application/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ], 13 | "compilerOptions": { 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/lending/application/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.spec.ts", 11 | "**/*.test.tsx", 12 | "**/*.spec.tsx", 13 | "**/*.test.js", 14 | "**/*.spec.js", 15 | "**/*.test.jsx", 16 | "**/*.spec.jsx", 17 | "**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /libs/lending/ui-rest/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ], 13 | "compilerOptions": { 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/lending/application/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'lending-application', 3 | preset: '../../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.spec.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../../coverage/libs/lending/application', 15 | }; 16 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ], 13 | "compilerOptions": { 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.spec.ts", 11 | "**/*.test.tsx", 12 | "**/*.spec.tsx", 13 | "**/*.test.js", 14 | "**/*.spec.js", 15 | "**/*.test.jsx", 16 | "**/*.spec.jsx", 17 | "**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /libs/shared/infrastructure-nestjs-cqrs-events/src/lib/nestjs-cqrs-domain-events.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent, DomainEvents } from '@library/shared/domain'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { EventBus } from '@nestjs/cqrs'; 4 | 5 | @Injectable() 6 | export class NestJSCqrsDomainEvents implements DomainEvents { 7 | constructor(private readonly eventBus: EventBus) {} 8 | 9 | publish(event: DomainEvent): void { 10 | this.eventBus.publish(event); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/value-objects/hold.ts: -------------------------------------------------------------------------------- 1 | import { TinyType } from 'tiny-types'; 2 | import { BookId } from './book-id'; 3 | import { LibraryBranchId } from './library-branch-id'; 4 | 5 | export class Hold extends TinyType { 6 | constructor( 7 | private readonly bookId: BookId, 8 | private readonly libraryBranchId: LibraryBranchId 9 | ) { 10 | super(); 11 | } 12 | 13 | forBook(bookId: BookId): boolean { 14 | return bookId.equals(this.bookId); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'lending-infrastructure', 3 | preset: '../../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.spec.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: '../../../coverage/libs/lending/infrastructure', 15 | }; 16 | -------------------------------------------------------------------------------- /libs/catalogue/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6", 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true 13 | }, 14 | "exclude": ["**/*.spec.ts", "**/*.test.ts"], 15 | "include": ["**/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /libs/shared/infrastructure-nestjs-cqrs-events/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "**/*.test.ts", 10 | "**/*.spec.ts", 11 | "**/*.test.tsx", 12 | "**/*.spec.tsx", 13 | "**/*.test.js", 14 | "**/*.spec.js", 15 | "**/*.test.jsx", 16 | "**/*.spec.jsx", 17 | "**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /libs/shared/infrastructure-nestjs-cqrs-events/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ], 13 | "compilerOptions": { 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/lending/domain/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6", 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true 13 | }, 14 | "exclude": ["**/*.spec.ts", "**/*.test.ts"], 15 | "include": ["**/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /libs/lending/ui-rest/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6", 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true 13 | }, 14 | "exclude": ["**/*.spec.ts", "**/*.test.ts"], 15 | "include": ["**/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /libs/lending/application/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6", 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true 13 | }, 14 | "exclude": ["**/*.spec.ts", "**/*.test.ts"], 15 | "include": ["**/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6", 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true 13 | }, 14 | "exclude": ["**/*.spec.ts", "**/*.test.ts"], 15 | "include": ["**/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /workspace.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "projects": { 4 | "catalogue": "libs/catalogue", 5 | "lending-application": "libs/lending/application", 6 | "lending-domain": "libs/lending/domain", 7 | "lending-infrastructure": "libs/lending/infrastructure", 8 | "lending-ui-rest": "libs/lending/ui-rest", 9 | "library": "apps/library", 10 | "shared-domain": "libs/shared/domain", 11 | "shared-infrastructure-nestjs-cqrs-events": "libs/shared/infrastructure-nestjs-cqrs-events" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/events/book-hold-canceled.ts: -------------------------------------------------------------------------------- 1 | import { BookId } from '../value-objects/book-id'; 2 | import { LibraryBranchId } from '../value-objects/library-branch-id'; 3 | import { PatronId } from '../value-objects/patron-id'; 4 | import { PatronEvent } from './patron-event'; 5 | 6 | export class BookHoldCanceled implements PatronEvent { 7 | constructor( 8 | public readonly patronId: PatronId, 9 | public readonly bookId: BookId, 10 | public readonly libraryBranchId: LibraryBranchId 11 | ) {} 12 | } 13 | -------------------------------------------------------------------------------- /libs/shared/infrastructure-nestjs-cqrs-events/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "../../../dist/out-tsc", 6 | "declaration": true, 7 | "types": ["node"], 8 | "target": "es6", 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true 13 | }, 14 | "exclude": ["**/*.spec.ts", "**/*.test.ts"], 15 | "include": ["**/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /libs/shared/infrastructure-nestjs-cqrs-events/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: 'shared-infrastructure-nestjs-cqrs-events', 3 | preset: '../../../jest.preset.js', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.spec.json', 7 | }, 8 | }, 9 | testEnvironment: 'node', 10 | transform: { 11 | '^.+\\.[tj]sx?$': 'ts-jest', 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 14 | coverageDirectory: 15 | '../../../coverage/libs/shared/infrastructure-nestjs-cqrs-events', 16 | }; 17 | -------------------------------------------------------------------------------- /apps/library/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | }, 17 | { 18 | "files": ["*.spec.ts"], 19 | "rules": { 20 | "@nrwl/nx/enforce-module-boundaries": "off" 21 | } 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/events/book-hold-failed.ts: -------------------------------------------------------------------------------- 1 | import { Rejection } from '../policies/placing-on-hold-policy'; 2 | import { PatronId } from '../value-objects/patron-id'; 3 | import { PatronEvent } from './patron-event'; 4 | 5 | export class BookHoldFailed implements PatronEvent { 6 | constructor( 7 | public readonly reason: string, 8 | public readonly patronId: PatronId 9 | ) {} 10 | 11 | static bookHoldFailedNow( 12 | rejection: Rejection, 13 | patronId: PatronId 14 | ): BookHoldFailed { 15 | return new BookHoldFailed(rejection.reason, patronId); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /libs/shared/infrastructure-nestjs-cqrs-events/src/lib/shared-infrastructure-nestjs-cqrs-events.module.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvents } from '@library/shared/domain'; 2 | import { Module } from '@nestjs/common'; 3 | import { CqrsModule } from '@nestjs/cqrs'; 4 | import { NestJSCqrsDomainEvents } from './nestjs-cqrs-domain-events'; 5 | 6 | @Module({ 7 | imports: [CqrsModule], 8 | providers: [ 9 | NestJSCqrsDomainEvents, 10 | { provide: DomainEvents, useExisting: NestJSCqrsDomainEvents }, 11 | ], 12 | exports: [DomainEvents], 13 | }) 14 | export class SharedInfrastructureNestjsCqrsEventsModule {} 15 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/isbn.spec.ts: -------------------------------------------------------------------------------- 1 | import { ISBN } from './isbn'; 2 | 3 | describe('ISBN', () => { 4 | it('isbn should be correct', () => { 5 | // when 6 | const isbn = new ISBN('123412341X'); 7 | // then 8 | expect(isbn.value).toEqual('123412341X'); 9 | }); 10 | 11 | it('isbn should be trimmed', () => { 12 | // when 13 | const isbn = new ISBN(' 1234123414 '); 14 | // then 15 | expect(isbn.value).toEqual('1234123414'); 16 | }); 17 | 18 | it('wrong isbn should not be accepted', () => { 19 | expect(() => new ISBN('not isbn')).toThrowError(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /apps/library/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger, ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | app.useGlobalPipes(new ValidationPipe()); 9 | const globalPrefix = 'api'; 10 | app.setGlobalPrefix(globalPrefix); 11 | const port = process.env.PORT || 3333; 12 | await app.listen(port); 13 | Logger.log( 14 | `🚀 Application is running on: http://localhost:${port}/${globalPrefix}` 15 | ); 16 | } 17 | 18 | bootstrap(); 19 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/events/book-placed-on-hold.ts: -------------------------------------------------------------------------------- 1 | import { BookId } from '../value-objects/book-id'; 2 | import { DateVO } from '../value-objects/date.vo'; 3 | import { LibraryBranchId } from '../value-objects/library-branch-id'; 4 | import { PatronId } from '../value-objects/patron-id'; 5 | import { PatronEvent } from './patron-event'; 6 | 7 | export class BookPlacedOnHold implements PatronEvent { 8 | constructor( 9 | public readonly patronId: PatronId, 10 | public readonly bookId: BookId, 11 | public readonly libraryBranchId: LibraryBranchId, 12 | public readonly till: DateVO | null 13 | ) {} 14 | } 15 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/events/book-check-out-failed.ts: -------------------------------------------------------------------------------- 1 | import { PatronId } from '../value-objects/patron-id'; 2 | import { Rejection } from '../policies/placing-on-hold-policy'; 3 | import { PatronEvent } from './patron-event'; 4 | 5 | export class BookCheckOutFailed implements PatronEvent { 6 | private constructor( 7 | public readonly reason: string, 8 | public readonly patronId: PatronId 9 | ) {} 10 | 11 | static bookCheckOutFailedBecause( 12 | rejection: Rejection, 13 | patronId: PatronId 14 | ): BookCheckOutFailed { 15 | return new BookCheckOutFailed(rejection.reason, patronId); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/library/src/migrations/Migration20221204160713.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | export class Migration20221204160713 extends Migration { 4 | async up(): Promise { 5 | this.addSql( 6 | 'create table "book" ("isbn" varchar(100) not null, "author" varchar(100) not null, "title" varchar(100) not null, constraint "book_pkey" primary key ("isbn"));' 7 | ); 8 | 9 | this.addSql( 10 | 'create table "book_instance" ("book_id" varchar(255) not null, "isbn" varchar(100) not null, "book_type" smallint not null, constraint "book_instance_pkey" primary key ("book_id"));' 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/src/lib/lending.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LendingApplicationModule } from '@library/lending/application'; 3 | import { LendingInfrastructureModule } from './lending-infrastructure.module'; 4 | import { SharedInfrastructureNestjsCqrsEventsModule } from '@library/shared/infrastructure-nestjs-cqrs-events'; 5 | 6 | @Module({ 7 | imports: [ 8 | LendingApplicationModule.withInfrastructure([ 9 | LendingInfrastructureModule, 10 | SharedInfrastructureNestjsCqrsEventsModule, 11 | ]), 12 | ], 13 | exports: [LendingApplicationModule], 14 | }) 15 | export class LendingModule {} 16 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/lending.facade.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@library/shared/domain'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { CommandBus } from '@nestjs/cqrs'; 4 | import { PlaceOnHoldCommand } from '..'; 5 | import { CancelHoldCommand } from './cancel-hold/cancel-hold.command'; 6 | 7 | @Injectable() 8 | export class LendingFacade { 9 | constructor(private readonly commandBus: CommandBus) {} 10 | 11 | cancelHold(command: CancelHoldCommand): Promise { 12 | return this.commandBus.execute(command); 13 | } 14 | 15 | placeOnHold(command: PlaceOnHoldCommand): Promise { 16 | return this.commandBus.execute(command); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/value-objects/patron-holds.ts: -------------------------------------------------------------------------------- 1 | import { BookOnHold } from '../book/book-on-hold'; 2 | import { Hold } from './hold'; 3 | 4 | export class PatronHolds { 5 | static MAX_NUMBER_OF_HOLDS = 5; 6 | constructor(private readonly resourcesOnHold: Set) {} 7 | 8 | get numberOfHolds(): number { 9 | return this.resourcesOnHold.size; 10 | } 11 | 12 | includes(book: BookOnHold): boolean { 13 | return !![...this.resourcesOnHold].find((hold) => 14 | hold.forBook(book.bookId) 15 | ); 16 | } 17 | 18 | maximumHoldsAfterHoldingNextBook(): boolean { 19 | return this.resourcesOnHold.size + 1 === PatronHolds.MAX_NUMBER_OF_HOLDS; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | # IDE 42 | .lh -------------------------------------------------------------------------------- /libs/lending/ui-rest/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/lending/ui-rest", 3 | "sourceRoot": "libs/lending/ui-rest/src", 4 | "projectType": "library", 5 | "targets": { 6 | "lint": { 7 | "executor": "@nrwl/linter:eslint", 8 | "outputs": ["{options.outputFile}"], 9 | "options": { 10 | "lintFilePatterns": ["libs/lending/ui-rest/**/*.ts"] 11 | } 12 | }, 13 | "test": { 14 | "executor": "@nrwl/jest:jest", 15 | "outputs": ["coverage/libs/lending/ui-rest"], 16 | "options": { 17 | "jestConfig": "libs/lending/ui-rest/jest.config.js", 18 | "passWithNoTests": true 19 | } 20 | } 21 | }, 22 | "tags": ["domain:lengning", "type:ui"] 23 | } 24 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/catalogue.module.ts: -------------------------------------------------------------------------------- 1 | import { SharedInfrastructureNestjsCqrsEventsModule } from '@library/shared/infrastructure-nestjs-cqrs-events'; 2 | import { MikroOrmModule } from '@mikro-orm/nestjs'; 3 | import { Module } from '@nestjs/common'; 4 | import { Book } from './book'; 5 | import { BookInstance } from './book-instance'; 6 | import { BookController } from './book.controller'; 7 | import { Catalogue } from './catalogue'; 8 | import { CatalogueDatabase } from './catalogue-database'; 9 | 10 | @Module({ 11 | imports: [ 12 | SharedInfrastructureNestjsCqrsEventsModule, 13 | MikroOrmModule.forFeature([Book, BookInstance]), 14 | ], 15 | controllers: [BookController], 16 | providers: [Catalogue, CatalogueDatabase], 17 | }) 18 | export class CatalogueModule {} 19 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmScope": "library", 3 | "affected": { 4 | "defaultBase": "main" 5 | }, 6 | "cli": { 7 | "defaultCollection": "@nrwl/nest" 8 | }, 9 | "implicitDependencies": { 10 | "package.json": { 11 | "dependencies": "*", 12 | "devDependencies": "*" 13 | }, 14 | ".eslintrc.json": "*" 15 | }, 16 | "tasksRunnerOptions": { 17 | "default": { 18 | "runner": "@nrwl/workspace/tasks-runners/default", 19 | "options": { 20 | "cacheableOperations": ["build", "lint", "test", "e2e"] 21 | } 22 | } 23 | }, 24 | "targetDependencies": { 25 | "build": [ 26 | { 27 | "target": "build", 28 | "projects": "dependencies" 29 | } 30 | ] 31 | }, 32 | "defaultProject": "library" 33 | } 34 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/book-instance-added-to-catalogue.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@library/shared/domain'; 2 | import { BookInstance } from './book-instance'; 3 | import { BookType } from './book-type'; 4 | 5 | export class BookInstanceAddedToCatalogue { 6 | constructor( 7 | public readonly eventId = Uuid.generate(), 8 | public readonly isbn: string, 9 | public readonly bookType: BookType, 10 | public readonly bookId: Uuid 11 | ) {} 12 | 13 | static fromBookInstance( 14 | bookInstance: BookInstance 15 | ): BookInstanceAddedToCatalogue { 16 | const bookInstanceSnapshot = bookInstance.getSnapshot(); 17 | return new BookInstanceAddedToCatalogue( 18 | undefined, 19 | bookInstanceSnapshot.isbn.value, 20 | bookInstanceSnapshot.bookType, 21 | bookInstanceSnapshot.bookId 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/book/available-book.ts: -------------------------------------------------------------------------------- 1 | import { Version } from '@library/shared/domain'; 2 | import { BookPlacedOnHold } from '../events/book-placed-on-hold'; 3 | import { BookId } from '../value-objects/book-id'; 4 | import { LibraryBranchId } from '../value-objects/library-branch-id'; 5 | import { Book } from './book'; 6 | import { BookOnHold } from './book-on-hold'; 7 | 8 | export class AvailableBook implements Book { 9 | constructor( 10 | public readonly bookId: BookId, 11 | public readonly libraryBranchId: LibraryBranchId, 12 | public readonly version: Version 13 | ) {} 14 | 15 | handleBookPlacedOnHold(bookPlacedOnHold: BookPlacedOnHold): BookOnHold { 16 | return new BookOnHold( 17 | this.bookId, 18 | this.libraryBranchId, 19 | bookPlacedOnHold.patronId, 20 | this.version 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /libs/lending/domain/tests/book.fixtures.ts: -------------------------------------------------------------------------------- 1 | import { Version } from '@library/shared/domain'; 2 | import { PatronId } from '../src'; 3 | import { AvailableBook } from '../src/lib/book/available-book'; 4 | import { BookOnHold } from '../src/lib/book/book-on-hold'; 5 | import { BookId } from '../src/lib/value-objects/book-id'; 6 | import { LibraryBranchId } from '../src/lib/value-objects/library-branch-id'; 7 | 8 | export class BookFixtures { 9 | static bookOnHold(): BookOnHold { 10 | return new BookOnHold( 11 | BookId.generate(), 12 | LibraryBranchId.generate(), 13 | PatronId.generate(), 14 | new Version(1) 15 | ); 16 | } 17 | 18 | static circulatingBook(): AvailableBook { 19 | return new AvailableBook( 20 | BookId.generate(), 21 | LibraryBranchId.generate(), 22 | Version.zero() 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/duplicate-hold.event.handler.ts: -------------------------------------------------------------------------------- 1 | import { BookDuplicateHoldFound } from '@library/lending/domain'; 2 | import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; 3 | import { CancelHoldCommand } from './cancel-hold/cancel-hold.command'; 4 | import { CancelHoldHandler } from './cancel-hold/cancel-hold.handler'; 5 | 6 | @EventsHandler(BookDuplicateHoldFound) 7 | export class DuplicateHoldEventHandler 8 | implements IEventHandler 9 | { 10 | constructor(private readonly cancelHold: CancelHoldHandler) {} 11 | 12 | handle(event: BookDuplicateHoldFound) { 13 | return this.cancelHold.execute(this.cancelHoldCommandFromEvent(event)); 14 | } 15 | 16 | cancelHoldCommandFromEvent(event: BookDuplicateHoldFound): CancelHoldCommand { 17 | return new CancelHoldCommand(event.secondPatronId, event.bookId); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/value-objects/hold-duration.ts: -------------------------------------------------------------------------------- 1 | import { ensure, isGreaterThanOrEqualTo, TinyType } from 'tiny-types'; 2 | import { DateVO } from './date.vo'; 3 | import { NumberOfDays } from './number-of-days'; 4 | 5 | export class HoldDuration extends TinyType { 6 | private constructor( 7 | public readonly from: DateVO, 8 | public readonly to: DateVO | null 9 | ) { 10 | super(); 11 | if (to) { 12 | ensure( 13 | 'HoldDuration "to"', 14 | to.value.getTime(), 15 | isGreaterThanOrEqualTo(from.value.getTime()) 16 | ); 17 | } 18 | } 19 | 20 | public static closeEnded(days: NumberOfDays): HoldDuration { 21 | const to = DateVO.now().addDays(days.value); 22 | return new HoldDuration(DateVO.now(), to); 23 | } 24 | static openEnded(): HoldDuration { 25 | return new HoldDuration(DateVO.now(), null); 26 | } 27 | isOpenEnded(): boolean { 28 | return !this.to; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /libs/lending/domain/tests/patron-canceling-hold.spec.ts: -------------------------------------------------------------------------------- 1 | import { isRight } from 'fp-ts/Either'; 2 | import { BookFixtures } from './book.fixtures'; 3 | import { PatronFixtures } from './patron.fixtures'; 4 | 5 | describe('PatronCancelingHoldTest', () => { 6 | test('patron should be able to cancel his holds', () => { 7 | // given 8 | const forBook = BookFixtures.bookOnHold(); 9 | // and 10 | const patron = PatronFixtures.regularPatronWithHold(forBook); 11 | // when 12 | const cancelHold = patron.cancelHold(forBook); 13 | // then 14 | expect(isRight(cancelHold)).toBe(true); 15 | }); 16 | 17 | test('patron cannot cancel not his hold', () => { 18 | // given 19 | const forBook = BookFixtures.bookOnHold(); 20 | // and 21 | const patron = PatronFixtures.GivenRegularPatron(); 22 | // when 23 | const cancelHold = patron.cancelHold(forBook); 24 | // then 25 | expect(isRight(cancelHold)).toBe(false); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/create-available-book-on-instance-added.event-handler.ts: -------------------------------------------------------------------------------- 1 | import { EventsHandler, IEventHandler } from "@nestjs/cqrs"; 2 | import { BookInstanceAddedToCatalogue } from "@library/catalogue"; 3 | import { BookRepository } from "./ports/book.repository"; 4 | import { AvailableBook, BookId, LibraryBranchId } from "@library/lending/domain"; 5 | import { Version } from "@library/shared/domain"; 6 | 7 | @EventsHandler(BookInstanceAddedToCatalogue) 8 | export class CreateAvailableBookOnInstanceAddedEventHandler implements IEventHandler { 9 | constructor(private readonly bookRepository: BookRepository) {} 10 | handle(event: BookInstanceAddedToCatalogue) { 11 | this.bookRepository.save(new AvailableBook(new BookId(event.bookId.value), this.ourLibraryBranch(), Version.zero())) 12 | } 13 | ourLibraryBranch(): LibraryBranchId { 14 | //from properties 15 | return LibraryBranchId.generate() 16 | } 17 | 18 | } -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/book/book-on-hold.ts: -------------------------------------------------------------------------------- 1 | import { Version } from '@library/shared/domain'; 2 | import { BookHoldCanceled } from '../events/book-hold-canceled'; 3 | import { BookId } from '../value-objects/book-id'; 4 | import { LibraryBranchId } from '../value-objects/library-branch-id'; 5 | import { PatronId } from '../value-objects/patron-id'; 6 | import { AvailableBook } from './available-book'; 7 | import { Book } from './book'; 8 | 9 | export class BookOnHold implements Book { 10 | constructor( 11 | public readonly bookId: BookId, 12 | public readonly libraryBranchId: LibraryBranchId, 13 | public readonly patronId: PatronId, 14 | public readonly version: Version 15 | ) {} 16 | 17 | by(patronId: PatronId): boolean { 18 | return this.patronId.equals(patronId); 19 | } 20 | 21 | handleHoldCanceled(holdCanceled: BookHoldCanceled): AvailableBook { 22 | return new AvailableBook( 23 | this.bookId, 24 | holdCanceled.libraryBranchId, 25 | this.version 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/book-instance.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Enum, PrimaryKey, Property } from '@mikro-orm/core'; 2 | import { BookId } from './book-id'; 3 | import { BookType } from './book-type'; 4 | import { ISBNType } from './custom-db-types'; 5 | import { ISBN } from './isbn'; 6 | 7 | @Entity() 8 | export class BookInstance { 9 | @Property({ type: ISBNType }) 10 | private isbn!: ISBN; 11 | @Enum(() => BookType) 12 | private bookType!: BookType; 13 | @PrimaryKey() 14 | private bookId!: BookId; 15 | 16 | static instanceOf(isbn: ISBN, bookType: BookType): BookInstance { 17 | const instance = new BookInstance(); 18 | instance.isbn = isbn; 19 | instance.bookType = bookType; 20 | instance.bookId = BookId.generate(); 21 | return instance; 22 | } 23 | 24 | getSnapshot(): BookInstanceSnapshot { 25 | return { isbn: this.isbn, bookId: this.bookId, bookType: this.bookType }; 26 | } 27 | } 28 | 29 | export interface BookInstanceSnapshot { 30 | isbn: ISBN; 31 | bookType: BookType; 32 | bookId: BookId; 33 | } 34 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/place-on-hold/place-on-hold.command.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BookId, 3 | HoldDuration, 4 | NumberOfDays, 5 | PatronId, 6 | } from '@library/lending/domain'; 7 | import { Command } from '@nestjs-architects/typed-cqrs'; 8 | import { pipe } from 'fp-ts/lib/function'; 9 | import { getOrElse, map, Option, some } from 'fp-ts/Option'; 10 | 11 | export class PlaceOnHoldCommand extends Command { 12 | static closeEnded( 13 | patron: PatronId, 14 | bookId: BookId, 15 | forDays: number 16 | ): PlaceOnHoldCommand { 17 | return new PlaceOnHoldCommand(patron, bookId, some(forDays)); 18 | } 19 | 20 | constructor( 21 | public readonly patron: PatronId, 22 | public readonly bookId: BookId, 23 | public readonly noOfDays: Option 24 | ) { 25 | super(); 26 | } 27 | 28 | get holdDuration(): HoldDuration { 29 | return pipe( 30 | this.noOfDays, 31 | map(NumberOfDays.of), 32 | map(HoldDuration.closeEnded), 33 | getOrElse(HoldDuration.openEnded) 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nrwl/nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "rules": { 9 | "@nrwl/nx/enforce-module-boundaries": [ 10 | "error", 11 | { 12 | "enforceBuildableLibDependency": true, 13 | "allow": [], 14 | "depConstraints": [ 15 | { 16 | "sourceTag": "*", 17 | "onlyDependOnLibsWithTags": ["*"] 18 | }, 19 | { 20 | "sourceTag": "domain:lending", 21 | "onlyDependOnLibsWithTags": ["domain:lending", "domain:shared"] 22 | } 23 | ] 24 | } 25 | ] 26 | } 27 | }, 28 | { 29 | "files": ["*.ts", "*.tsx"], 30 | "extends": ["plugin:@nrwl/nx/typescript"], 31 | "rules": {} 32 | }, 33 | { 34 | "files": ["*.js", "*.jsx"], 35 | "extends": ["plugin:@nrwl/nx/javascript"], 36 | "rules": {} 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /libs/catalogue/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/catalogue", 3 | "sourceRoot": "libs/catalogue/src", 4 | "projectType": "library", 5 | "targets": { 6 | "build": { 7 | "executor": "@nrwl/node:package", 8 | "outputs": ["{options.outputPath}"], 9 | "options": { 10 | "outputPath": "dist/libs/catalogue", 11 | "tsConfig": "libs/catalogue/tsconfig.lib.json", 12 | "packageJson": "libs/catalogue/package.json", 13 | "main": "libs/catalogue/src/index.ts", 14 | "assets": ["libs/catalogue/*.md"] 15 | } 16 | }, 17 | "lint": { 18 | "executor": "@nrwl/linter:eslint", 19 | "outputs": ["{options.outputFile}"], 20 | "options": { 21 | "lintFilePatterns": ["libs/catalogue/**/*.ts"] 22 | } 23 | }, 24 | "test": { 25 | "executor": "@nrwl/jest:jest", 26 | "outputs": ["coverage/libs/catalogue"], 27 | "options": { 28 | "jestConfig": "libs/catalogue/jest.config.js", 29 | "passWithNoTests": true 30 | } 31 | } 32 | }, 33 | "tags": ["domain:catalogue", "type:crud"] 34 | } 35 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/events/book-placed-on-hold-events.ts: -------------------------------------------------------------------------------- 1 | import { PatronId } from '../..'; 2 | import { BookPlacedOnHold } from './book-placed-on-hold'; 3 | import { MaximumNumberOhHoldsReached } from './maximum-number-on-holds-reached'; 4 | import { PatronEvent } from './patron-event'; 5 | 6 | export class BookPlacedOnHoldEvents implements PatronEvent { 7 | private constructor( 8 | public readonly patronId: PatronId, 9 | public readonly bookPlacedOnHold: BookPlacedOnHold, 10 | public readonly maximumNumberOhHoldsReached?: MaximumNumberOhHoldsReached 11 | ) {} 12 | static event( 13 | patronId: PatronId, 14 | bookPlacedOnHold: BookPlacedOnHold 15 | ): BookPlacedOnHoldEvents { 16 | return new BookPlacedOnHoldEvents(patronId, bookPlacedOnHold); 17 | } 18 | static events( 19 | patronId: PatronId, 20 | bookPlacedOnHold: BookPlacedOnHold, 21 | maximumNumberOnHoldsReached: MaximumNumberOhHoldsReached 22 | ): BookPlacedOnHoldEvents { 23 | return new BookPlacedOnHoldEvents( 24 | patronId, 25 | bookPlacedOnHold, 26 | maximumNumberOnHoldsReached 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/factories/patron.factory.ts: -------------------------------------------------------------------------------- 1 | import { Patron } from '../patron'; 2 | import { allCurrentPolicies } from '../policies/placing-on-hold-policy'; 3 | import { BookId } from '../value-objects/book-id'; 4 | import { Hold } from '../value-objects/hold'; 5 | import { LibraryBranchId } from '../value-objects/library-branch-id'; 6 | import { PatronHolds } from '../value-objects/patron-holds'; 7 | import { PatronId } from '../value-objects/patron-id'; 8 | import { PatronInformation } from '../value-objects/patron-information'; 9 | import { PatronType } from '../value-objects/patron-type'; 10 | 11 | export class PatronFactory { 12 | create( 13 | patronType: PatronType, 14 | patronId: PatronId, 15 | patronHolds: Set<[BookId, LibraryBranchId]> 16 | ): Patron { 17 | return new Patron( 18 | new PatronHolds( 19 | new Set( 20 | [...patronHolds].map( 21 | ([bookId, libraryBranchId]) => new Hold(bookId, libraryBranchId) 22 | ) 23 | ) 24 | ), 25 | allCurrentPolicies, 26 | new PatronInformation(patronId, patronType) 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /libs/shared/domain/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/shared/domain", 3 | "sourceRoot": "libs/shared/domain/src", 4 | "projectType": "library", 5 | "targets": { 6 | "build": { 7 | "executor": "@nrwl/node:package", 8 | "outputs": ["{options.outputPath}"], 9 | "options": { 10 | "outputPath": "dist/libs/shared/domain", 11 | "tsConfig": "libs/shared/domain/tsconfig.lib.json", 12 | "packageJson": "libs/shared/domain/package.json", 13 | "main": "libs/shared/domain/src/index.ts", 14 | "assets": ["libs/shared/domain/*.md"] 15 | } 16 | }, 17 | "lint": { 18 | "executor": "@nrwl/linter:eslint", 19 | "outputs": ["{options.outputFile}"], 20 | "options": { 21 | "lintFilePatterns": ["libs/shared/domain/**/*.ts"] 22 | } 23 | }, 24 | "test": { 25 | "executor": "@nrwl/jest:jest", 26 | "outputs": ["coverage/libs/shared/domain"], 27 | "options": { 28 | "jestConfig": "libs/shared/domain/jest.config.js", 29 | "passWithNoTests": true 30 | } 31 | } 32 | }, 33 | "tags": ["domain:shared", "type:domain"] 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Maciej Sikorski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /libs/lending/domain/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/book/available-book'; 2 | export * from './lib/book/book'; 3 | export * from './lib/book/book-on-hold'; 4 | export * from './lib/events/book-duplicate-hold-found.event'; 5 | export * from './lib/events/book-check-out-failed'; 6 | export * from './lib/events/book-checked-out'; 7 | export * from './lib/events/book-hold-canceled'; 8 | export * from './lib/events/book-hold-canceling-failed'; 9 | export * from './lib/events/book-hold-failed'; 10 | export * from './lib/events/book-placed-on-hold'; 11 | export * from './lib/events/book-placed-on-hold-events'; 12 | export * from './lib/events/patron-event'; 13 | export * from './lib/factories/patron.factory'; 14 | export * from './lib/patron'; 15 | export * from './lib/value-objects/book-id'; 16 | export * from './lib/value-objects/date.vo'; 17 | export * from './lib/value-objects/hold'; 18 | export * from './lib/value-objects/hold-duration'; 19 | export * from './lib/value-objects/library-branch-id'; 20 | export * from './lib/value-objects/number-of-days'; 21 | export * from './lib/value-objects/patron-id'; 22 | export * from './lib/value-objects/patron-type'; 23 | -------------------------------------------------------------------------------- /libs/lending/domain/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/lending/domain", 3 | "sourceRoot": "libs/lending/domain/src", 4 | "projectType": "library", 5 | "targets": { 6 | "build": { 7 | "executor": "@nrwl/node:package", 8 | "outputs": ["{options.outputPath}"], 9 | "options": { 10 | "outputPath": "dist/libs/lending/domain", 11 | "tsConfig": "libs/lending/domain/tsconfig.lib.json", 12 | "packageJson": "libs/lending/domain/package.json", 13 | "main": "libs/lending/domain/src/index.ts", 14 | "assets": ["libs/lending/domain/*.md"] 15 | } 16 | }, 17 | "lint": { 18 | "executor": "@nrwl/linter:eslint", 19 | "outputs": ["{options.outputFile}"], 20 | "options": { 21 | "lintFilePatterns": ["libs/lending/domain/**/*.ts"] 22 | } 23 | }, 24 | "test": { 25 | "executor": "@nrwl/jest:jest", 26 | "outputs": ["coverage/libs/lending/domain"], 27 | "options": { 28 | "jestConfig": "libs/lending/domain/jest.config.js", 29 | "passWithNoTests": true 30 | } 31 | } 32 | }, 33 | "tags": ["domain:lending", "type:domain"] 34 | } 35 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/catalogue-database.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository } from '@mikro-orm/core'; 2 | import { InjectRepository } from '@mikro-orm/nestjs'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { fromNullable, Option } from 'fp-ts/Option'; 5 | import { Repository } from 'typeorm'; 6 | import { Book } from './book'; 7 | import { BookInstance } from './book-instance'; 8 | import { ISBN } from './isbn'; 9 | 10 | @Injectable() 11 | export class CatalogueDatabase { 12 | constructor( 13 | @InjectRepository(Book) 14 | private readonly bookRepository: EntityRepository, 15 | @InjectRepository(BookInstance) 16 | private readonly bookInstanceRepository: EntityRepository 17 | ) {} 18 | async findBookByIsbn(isbn: ISBN): Promise> { 19 | return fromNullable(await this.bookRepository.findOne(isbn.value)); 20 | } 21 | async saveNewBook(book: Book): Promise { 22 | await this.bookRepository.persistAndFlush(book); 23 | return book; 24 | } 25 | async saveNewBookInstance(bookInstance: BookInstance) { 26 | await this.bookInstanceRepository.persistAndFlush(bookInstance); 27 | return bookInstance; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "target": "es2015", 12 | "module": "esnext", 13 | "lib": ["es2017", "dom"], 14 | "skipLibCheck": true, 15 | "skipDefaultLibCheck": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "@library/catalogue": ["libs/catalogue/src/index.ts"], 19 | "@library/lending/application": ["libs/lending/application/src/index.ts"], 20 | "@library/lending/domain": ["libs/lending/domain/src/index.ts"], 21 | "@library/lending/infrastructure": [ 22 | "libs/lending/infrastructure/src/index.ts" 23 | ], 24 | "@library/lending/ui-rest": ["libs/lending/ui-rest/src/index.ts"], 25 | "@library/shared/domain": ["libs/shared/domain/src/index.ts"], 26 | "@library/shared/infrastructure-nestjs-cqrs-events": [ 27 | "libs/shared/infrastructure-nestjs-cqrs-events/src/index.ts" 28 | ] 29 | } 30 | }, 31 | "exclude": ["node_modules", "tmp"] 32 | } 33 | -------------------------------------------------------------------------------- /libs/lending/application/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/lending/application", 3 | "sourceRoot": "libs/lending/application/src", 4 | "projectType": "library", 5 | "targets": { 6 | "build": { 7 | "executor": "@nrwl/node:package", 8 | "outputs": ["{options.outputPath}"], 9 | "options": { 10 | "outputPath": "dist/libs/lending/application", 11 | "tsConfig": "libs/lending/application/tsconfig.lib.json", 12 | "packageJson": "libs/lending/application/package.json", 13 | "main": "libs/lending/application/src/index.ts", 14 | "assets": ["libs/lending/application/*.md"] 15 | } 16 | }, 17 | "lint": { 18 | "executor": "@nrwl/linter:eslint", 19 | "outputs": ["{options.outputFile}"], 20 | "options": { 21 | "lintFilePatterns": ["libs/lending/application/**/*.ts"] 22 | } 23 | }, 24 | "test": { 25 | "executor": "@nrwl/jest:jest", 26 | "outputs": ["coverage/libs/lending/application"], 27 | "options": { 28 | "jestConfig": "libs/lending/application/jest.config.js", 29 | "passWithNoTests": true 30 | } 31 | } 32 | }, 33 | "tags": ["domain:lending", "type:application"] 34 | } 35 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/lending/infrastructure", 3 | "sourceRoot": "libs/lending/infrastructure/src", 4 | "projectType": "library", 5 | "targets": { 6 | "build": { 7 | "executor": "@nrwl/node:package", 8 | "outputs": ["{options.outputPath}"], 9 | "options": { 10 | "outputPath": "dist/libs/lending/infrastructure", 11 | "tsConfig": "libs/lending/infrastructure/tsconfig.lib.json", 12 | "packageJson": "libs/lending/infrastructure/package.json", 13 | "main": "libs/lending/infrastructure/src/index.ts", 14 | "assets": ["libs/lending/infrastructure/*.md"] 15 | } 16 | }, 17 | "lint": { 18 | "executor": "@nrwl/linter:eslint", 19 | "outputs": ["{options.outputFile}"], 20 | "options": { 21 | "lintFilePatterns": ["libs/lending/infrastructure/**/*.ts"] 22 | } 23 | }, 24 | "test": { 25 | "executor": "@nrwl/jest:jest", 26 | "outputs": ["coverage/libs/lending/infrastructure"], 27 | "options": { 28 | "jestConfig": "libs/lending/infrastructure/jest.config.js", 29 | "passWithNoTests": true 30 | } 31 | } 32 | }, 33 | "tags": ["domain:lending", "type:infrastructure"] 34 | } 35 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/book-hold-canceled.event-handler.ts: -------------------------------------------------------------------------------- 1 | import { Book, BookHoldCanceled, BookOnHold } from '@library/lending/domain'; 2 | import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; 3 | import { pipe } from 'fp-ts/function'; 4 | import { map } from 'fp-ts/Option'; 5 | import { BookRepository } from './ports/book.repository'; 6 | 7 | @EventsHandler(BookHoldCanceled) 8 | export class BookHoldCanceledEventHandler 9 | implements IEventHandler 10 | { 11 | constructor(private readonly bookRepository: BookRepository) {} 12 | 13 | async handle(bookHoldCanceled: BookHoldCanceled): Promise { 14 | const maybeBook = await this.bookRepository.findById( 15 | bookHoldCanceled.bookId 16 | ); 17 | pipe( 18 | maybeBook, 19 | map((book) => this.handleHoldCanceled(book, bookHoldCanceled)), 20 | map(this.saveBook.bind(this)) 21 | ); 22 | } 23 | handleHoldCanceled(book: Book, holdCanceled: BookHoldCanceled): Book { 24 | switch (book.constructor) { 25 | case BookOnHold: 26 | return (book as BookOnHold).handleHoldCanceled(holdCanceled); 27 | default: 28 | return book; 29 | } 30 | } 31 | 32 | private async saveBook(book: Book): Promise { 33 | await this.bookRepository.save(book); 34 | return book; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/book.controller.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@library/shared/domain'; 2 | import { 3 | Controller, 4 | Get, 5 | Post, 6 | Body, 7 | Patch, 8 | Param, 9 | Delete, 10 | } from '@nestjs/common'; 11 | import { Catalogue } from './catalogue'; 12 | import { CreateBookDto } from './dto/create-book.dto'; 13 | import { UpdateBookDto } from './dto/update-book.dto'; 14 | 15 | @Controller('book') 16 | export class BookController { 17 | constructor(private readonly catalogue: Catalogue) {} 18 | 19 | @Post() 20 | async create(@Body() createBookDto: CreateBookDto): Promise { 21 | await this.catalogue.addBook( 22 | createBookDto.author, 23 | createBookDto.title, 24 | createBookDto.isbn 25 | ); 26 | return this.catalogue.addBookInstance( 27 | createBookDto.isbn, 28 | createBookDto.bookType 29 | ); 30 | } 31 | 32 | @Get() 33 | findAll() { 34 | return this.catalogue.findAll(); 35 | } 36 | 37 | @Get(':id') 38 | findOne(@Param('id') id: string) { 39 | return this.catalogue.findOne(+id); 40 | } 41 | 42 | @Patch(':id') 43 | update(@Param('id') id: string, @Body() updateBookDto: UpdateBookDto) { 44 | return this.catalogue.update(+id, updateBookDto); 45 | } 46 | 47 | @Delete(':id') 48 | remove(@Param('id') id: string) { 49 | return this.catalogue.remove(+id); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /libs/shared/infrastructure-nestjs-cqrs-events/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "libs/shared/infrastructure-nestjs-cqrs-events", 3 | "sourceRoot": "libs/shared/infrastructure-nestjs-cqrs-events/src", 4 | "projectType": "library", 5 | "targets": { 6 | "build": { 7 | "executor": "@nrwl/node:package", 8 | "outputs": ["{options.outputPath}"], 9 | "options": { 10 | "outputPath": "dist/libs/shared/infrastructure-nestjs-cqrs-events", 11 | "tsConfig": "libs/shared/infrastructure-nestjs-cqrs-events/tsconfig.lib.json", 12 | "packageJson": "libs/shared/infrastructure-nestjs-cqrs-events/package.json", 13 | "main": "libs/shared/infrastructure-nestjs-cqrs-events/src/index.ts", 14 | "assets": ["libs/shared/infrastructure-nestjs-cqrs-events/*.md"] 15 | } 16 | }, 17 | "lint": { 18 | "executor": "@nrwl/linter:eslint", 19 | "outputs": ["{options.outputFile}"], 20 | "options": { 21 | "lintFilePatterns": [ 22 | "libs/shared/infrastructure-nestjs-cqrs-events/**/*.ts" 23 | ] 24 | } 25 | }, 26 | "test": { 27 | "executor": "@nrwl/jest:jest", 28 | "outputs": ["coverage/libs/shared/infrastructure-nestjs-cqrs-events"], 29 | "options": { 30 | "jestConfig": "libs/shared/infrastructure-nestjs-cqrs-events/jest.config.js", 31 | "passWithNoTests": true 32 | } 33 | } 34 | }, 35 | "tags": ["type:infrastructure"] 36 | } 37 | -------------------------------------------------------------------------------- /libs/lending/ui-rest/src/lib/patron-profile/patron-profile.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LendingFacade, 3 | PlaceOnHoldCommand, 4 | } from '@library/lending/application'; 5 | import { BookId, PatronId } from '@library/lending/domain'; 6 | import { 7 | Body, 8 | Controller, 9 | Delete, 10 | HttpCode, 11 | Param, 12 | ParseUUIDPipe, 13 | Post, 14 | } from '@nestjs/common'; 15 | import { fromNullable, of } from 'fp-ts/Option'; 16 | import { CancelHoldCommand } from '@library/lending/application'; 17 | import { PlaceOnHoldDto } from './dtos/place-on-hold.dto'; 18 | 19 | @Controller('profiles') 20 | export class PatronProfileController { 21 | constructor(private readonly lendingFacade: LendingFacade) {} 22 | 23 | @Post(':patronId/holds') 24 | placeHold( 25 | @Param('patronId', ParseUUIDPipe) patronId: string, 26 | @Body() body: PlaceOnHoldDto 27 | ) { 28 | return this.lendingFacade.placeOnHold( 29 | new PlaceOnHoldCommand( 30 | new PatronId(patronId), 31 | new BookId(body.bookId), 32 | fromNullable(body.numberOfDays) 33 | ) 34 | ); 35 | } 36 | 37 | @HttpCode(204) 38 | @Delete(':patronId/holds/:bookId') 39 | cancelHold( 40 | @Param('patronId', ParseUUIDPipe) patronId: string, 41 | @Param('bookId', ParseUUIDPipe) bookId: string 42 | ) { 43 | return this.lendingFacade.cancelHold( 44 | new CancelHoldCommand(new PatronId(patronId), new BookId(bookId)) 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /apps/library/src/app/app-core.module.ts: -------------------------------------------------------------------------------- 1 | import { MikroORM } from '@mikro-orm/core'; 2 | import { MikroOrmModule } from '@mikro-orm/nestjs'; 3 | import { Module, OnModuleInit } from '@nestjs/common'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { Migration20221204160713 } from '../migrations/Migration20221204160713'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule.forRoot(), 11 | TypeOrmModule.forRoot({ 12 | type: 'postgres', 13 | host: process.env.DB_HOST, 14 | port: 5432, 15 | username: process.env.DB_USER, 16 | password: process.env.DB_PASSWORD, 17 | database: process.env.DB_NAME, 18 | autoLoadEntities: true, 19 | synchronize: true, 20 | }), 21 | MikroOrmModule.forRoot({ 22 | host: process.env.DB_HOST, 23 | port: 5432, 24 | user: process.env.DB_USER, 25 | password: process.env.DB_PASSWORD, 26 | dbName: process.env.DB_NAME, 27 | autoLoadEntities: true, 28 | schemaGenerator: { 29 | disableForeignKeys: true, 30 | createForeignKeyConstraints: true, 31 | ignoreSchema: [], 32 | }, 33 | type: 'postgresql', 34 | migrations: { 35 | migrationsList: [{ name: 'Initial', class: Migration20221204160713 }], 36 | }, 37 | }), 38 | ], 39 | }) 40 | export class AppCoreModule implements OnModuleInit { 41 | constructor(private readonly orm: MikroORM) {} 42 | 43 | async onModuleInit(): Promise { 44 | await this.orm.getMigrator().up(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/book.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; 2 | import { ensure, isString, Predicate, TinyTypeOf } from 'tiny-types'; 3 | import { BookInstance } from './book-instance'; 4 | import { BookType } from './book-type'; 5 | import { AuthorType, ISBNType, TitleType } from './custom-db-types'; 6 | import { ISBN } from './isbn'; 7 | 8 | export class Title extends TinyTypeOf() { 9 | constructor(title: string) { 10 | super(title); 11 | ensure( 12 | 'Title', 13 | title, 14 | isString(), 15 | Predicate.to('not be empty', (value) => value !== '') 16 | ); 17 | } 18 | } 19 | 20 | export class Author extends TinyTypeOf() { 21 | constructor(author: string) { 22 | super(author); 23 | ensure( 24 | 'Author', 25 | author, 26 | isString(), 27 | Predicate.to('not be empty', (value) => value !== '') 28 | ); 29 | } 30 | } 31 | 32 | @Entity() 33 | export class Book { 34 | @Property({ type: AuthorType }) 35 | private author!: Author; 36 | 37 | @PrimaryKey({ type: ISBNType }) 38 | private isbn!: ISBN; 39 | 40 | @Property({ type: TitleType }) 41 | private title!: Title; 42 | static create(isbn: string, author: string, title: string): Book { 43 | const book = new Book(); 44 | book.isbn = new ISBN(isbn); 45 | book.title = new Title(title); 46 | book.author = new Author(author); 47 | return book; 48 | } 49 | 50 | toAnInstance(bookType: BookType): BookInstance { 51 | return BookInstance.instanceOf(this.isbn, bookType); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/lending-application.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common'; 2 | import { CqrsModule } from '@nestjs/cqrs'; 3 | import { BookPlacedOnHoldEventHandler } from './book-placed-on-hold.event-handler'; 4 | import { CancelHoldHandler } from './cancel-hold/cancel-hold.handler'; 5 | import { CheckOutBookHandler } from './check-out/check-out-book.handler'; 6 | import { LendingFacade } from './lending.facade'; 7 | import { PlaceOnHoldHandler } from './place-on-hold/place-on-hold.handler'; 8 | import { DuplicateHoldEventHandler } from './duplicate-hold.event.handler'; 9 | import { BookHoldCanceledEventHandler } from './book-hold-canceled.event-handler'; 10 | import { CreateAvailableBookOnInstanceAddedEventHandler } from './create-available-book-on-instance-added.event-handler'; 11 | 12 | @Module({ 13 | imports: [CqrsModule], 14 | providers: [ 15 | BookHoldCanceledEventHandler, 16 | BookPlacedOnHoldEventHandler, 17 | CancelHoldHandler, 18 | CheckOutBookHandler, 19 | CreateAvailableBookOnInstanceAddedEventHandler, 20 | DuplicateHoldEventHandler, 21 | LendingFacade, 22 | PlaceOnHoldHandler, 23 | ], 24 | exports: [LendingFacade], 25 | }) 26 | export class LendingApplicationModule { 27 | static withInfrastructure( 28 | infrastructure: ModuleMetadata['imports'] 29 | ): DynamicModule { 30 | infrastructure = infrastructure ?? []; 31 | return { 32 | module: LendingApplicationModule, 33 | imports: [...infrastructure], 34 | providers: [], 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/src/lib/typeorm/lending-typeorm.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BookRepository, 3 | FindAvailableBook, 4 | FindBookOnHold, 5 | PatronRepository, 6 | } from '@library/lending/application'; 7 | import { PatronFactory } from '@library/lending/domain'; 8 | import { SharedInfrastructureNestjsCqrsEventsModule } from '@library/shared/infrastructure-nestjs-cqrs-events'; 9 | import { Module } from '@nestjs/common'; 10 | import { TypeOrmModule } from '@nestjs/typeorm'; 11 | import { BookEntity } from './entities/book.entity'; 12 | import { HoldEntity } from './entities/hold.entity'; 13 | import { PatronEntity } from './entities/patron.entity'; 14 | import { BookRepo } from './repositories/book.repository'; 15 | import { 16 | DomainModelMapper, 17 | PatronRepo, 18 | } from './repositories/patron.repository'; 19 | 20 | @Module({ 21 | imports: [ 22 | // @ToDo move it from here 23 | SharedInfrastructureNestjsCqrsEventsModule, 24 | TypeOrmModule.forFeature([BookEntity, PatronEntity, HoldEntity]), 25 | ], 26 | providers: [ 27 | BookRepo, 28 | PatronRepo, 29 | DomainModelMapper, 30 | PatronFactory, // @ToDo 31 | { provide: BookRepository, useExisting: BookRepo }, 32 | { provide: FindAvailableBook, useExisting: BookRepo }, 33 | { provide: FindBookOnHold, useExisting: BookRepo }, 34 | { provide: PatronRepository, useExisting: PatronRepo }, 35 | ], 36 | exports: [ 37 | BookRepository, 38 | FindAvailableBook, 39 | FindBookOnHold, 40 | PatronRepository, 41 | ], 42 | }) 43 | export class LendingTypeOrmModule {} 44 | -------------------------------------------------------------------------------- /apps/library/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "apps/library", 3 | "sourceRoot": "apps/library/src", 4 | "projectType": "application", 5 | "targets": { 6 | "build": { 7 | "executor": "@nrwl/node:build", 8 | "outputs": ["{options.outputPath}"], 9 | "options": { 10 | "outputPath": "dist/apps/library", 11 | "main": "apps/library/src/main.ts", 12 | "tsConfig": "apps/library/tsconfig.app.json", 13 | "assets": ["apps/library/src/assets"] 14 | }, 15 | "configurations": { 16 | "production": { 17 | "optimization": true, 18 | "extractLicenses": true, 19 | "inspect": false, 20 | "fileReplacements": [ 21 | { 22 | "replace": "apps/library/src/environments/environment.ts", 23 | "with": "apps/library/src/environments/environment.prod.ts" 24 | } 25 | ] 26 | } 27 | } 28 | }, 29 | "serve": { 30 | "executor": "@nrwl/node:execute", 31 | "options": { 32 | "buildTarget": "library:build" 33 | } 34 | }, 35 | "lint": { 36 | "executor": "@nrwl/linter:eslint", 37 | "outputs": ["{options.outputFile}"], 38 | "options": { 39 | "lintFilePatterns": ["apps/library/**/*.ts"] 40 | } 41 | }, 42 | "test": { 43 | "executor": "@nrwl/jest:jest", 44 | "outputs": ["coverage/apps/library"], 45 | "options": { 46 | "jestConfig": "apps/library/jest.config.js", 47 | "passWithNoTests": true 48 | } 49 | } 50 | }, 51 | "tags": [] 52 | } 53 | -------------------------------------------------------------------------------- /docs/example-mapping.md: -------------------------------------------------------------------------------- 1 | # Example Mapping 2 | 3 | After the Big Picture EventStorming we had a high level overview of the domain. The next step could have been 4 | to start Design Level EventStorming right away. EventStorming, considered as a tool, can be modified 5 | and adjusted to current needs, thus taking different forms. We could have dug deeper into the topic during Big Picture 6 | session and model all possible paths and scenarios with events, policies and rules then. In that case, 7 | it might have been difficult to spot particular business scenarios and prioritize them, as they would be spread 8 | throughout the wall. The alternative that we chose to apply, was to discover those scenarios first, and model each 9 | of them separately with Design Level EventStorming. The intermediate step is called Example Mapping. In the following 10 | paragraphs, you will find results of this session. 11 | 12 | _Please note that this is a simplified variation of Example Mapping, where we just group examples by business areas, 13 | not focusing on the rules, or use cases from the original technique_ 14 | 15 | ## Holding 16 | 17 | ![Holding](images/em/holding.png) 18 | 19 | ## Checkout 20 | 21 | ![Checkout](images/em/checking-out.png) 22 | 23 | ## Expiring hold 24 | 25 | ![Expiring hold](images/em/expiring-hold.png) 26 | 27 | ## Canceling hold 28 | 29 | ![Canceling hold](images/em/expiring-hold.png) 30 | 31 | ## Overdue checkouts 32 | 33 | ![Overdue checkouts](images/em/overdue-checkouts.png) 34 | 35 | ## Adding to catalogue 36 | 37 | ![Adding to catalogue](images/em/adding-to-catalogue.png) -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/policies/placing-on-hold-policy.ts: -------------------------------------------------------------------------------- 1 | import { Either, left, right } from 'fp-ts/lib/Either'; 2 | import { AvailableBook } from '../book/available-book'; 3 | import { Patron } from '../patron'; 4 | import { HoldDuration } from '../value-objects/hold-duration'; 5 | import { PatronHolds } from '../value-objects/patron-holds'; 6 | 7 | export interface PlacingOnHoldPolicy { 8 | (book: AvailableBook, patron: Patron, duration: HoldDuration): Either< 9 | Rejection, 10 | Allowance 11 | >; 12 | } 13 | 14 | export const regularPatronMaximumNumberOfHoldsPolicy: PlacingOnHoldPolicy = ( 15 | _toHold, 16 | patron 17 | ) => { 18 | if ( 19 | patron.isRegular() && 20 | patron.numberOfHolds() >= PatronHolds.MAX_NUMBER_OF_HOLDS 21 | ) { 22 | return left(Rejection.withReason('patron cannot hold more books')); 23 | } 24 | return right(new Allowance()); 25 | }; 26 | 27 | export const onlyResearcherPatronsCanPlaceOpenEndedHolds: PlacingOnHoldPolicy = 28 | (toHold: AvailableBook, patron: Patron, holdDuration: HoldDuration) => { 29 | if (patron.isRegular() && holdDuration.isOpenEnded()) { 30 | return left( 31 | Rejection.withReason('regular patron cannot place open ended holds') 32 | ); 33 | } 34 | return right(new Allowance()); 35 | }; 36 | 37 | export const allCurrentPolicies: Set = new Set([ 38 | regularPatronMaximumNumberOfHoldsPolicy, 39 | onlyResearcherPatronsCanPlaceOpenEndedHolds, 40 | ]); 41 | 42 | export class Allowance {} 43 | 44 | export class Rejection { 45 | private constructor(public readonly reason: string) {} 46 | 47 | static withReason(reason: string): Rejection { 48 | return new Rejection(reason); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/custom-db-types.ts: -------------------------------------------------------------------------------- 1 | import { EntityProperty, Platform, Type } from '@mikro-orm/core'; 2 | import { Author, Title } from './book'; 3 | import { BookId } from './book-id'; 4 | import { ISBN } from './isbn'; 5 | 6 | export class AuthorType extends Type { 7 | convertToDatabaseValue(value: Author, platform: Platform): string { 8 | return value.value; 9 | } 10 | 11 | convertToJSValue(value: string, platform: Platform): Author { 12 | return new Author(value); 13 | } 14 | 15 | getColumnType(prop: EntityProperty, platform: Platform) { 16 | return `varchar(100)`; 17 | } 18 | } 19 | 20 | export class TitleType extends Type { 21 | convertToDatabaseValue(value: Title, platform: Platform): string { 22 | return value.value; 23 | } 24 | 25 | convertToJSValue(value: string, platform: Platform): Title { 26 | return new Title(value); 27 | } 28 | 29 | getColumnType(prop: EntityProperty, platform: Platform) { 30 | return `varchar(100)`; 31 | } 32 | } 33 | 34 | export class ISBNType extends Type { 35 | convertToDatabaseValue(value: ISBN, platform: Platform): string { 36 | return value.value; 37 | } 38 | 39 | convertToJSValue(value: string, platform: Platform): ISBN { 40 | return new ISBN(value); 41 | } 42 | 43 | getColumnType(prop: EntityProperty, platform: Platform) { 44 | return `varchar(100)`; 45 | } 46 | } 47 | 48 | export class BookIdType extends Type { 49 | convertToDatabaseValue(value: BookId, platform: Platform): string { 50 | return value.value; 51 | } 52 | 53 | convertToJSValue(value: string, platform: Platform): BookId { 54 | return new BookId(value); 55 | } 56 | 57 | getColumnType(prop: EntityProperty, platform: Platform) { 58 | return `UUID`; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/src/lib/typeorm/entities/hold.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BookId, 3 | Hold, 4 | LibraryBranchId, 5 | PatronId, 6 | } from '@library/lending/domain'; 7 | import { 8 | Column, 9 | Entity, 10 | JoinColumn, 11 | ManyToOne, 12 | PrimaryGeneratedColumn, 13 | } from 'typeorm'; 14 | import { PatronEntity } from './patron.entity'; 15 | 16 | @Entity() 17 | export class HoldEntity { 18 | @PrimaryGeneratedColumn() 19 | id!: number; 20 | 21 | @Column({ 22 | type: 'uuid', 23 | }) 24 | libraryBranchId!: string; 25 | 26 | @Column({ 27 | type: 'uuid', 28 | }) 29 | bookId!: string; 30 | 31 | @ManyToOne(() => PatronEntity, { orphanedRowAction: 'delete' }) 32 | @JoinColumn({ name: 'patronId' }) 33 | patron!: PatronEntity; 34 | 35 | @Column({ 36 | type: 'uuid', 37 | }) 38 | patronId!: string; 39 | 40 | static create( 41 | data: Omit< 42 | HoldEntity, 43 | | 'id' 44 | | 'is' 45 | | 'toHoldModel' 46 | | 'getLibraryBranchId' 47 | | 'getBookId' 48 | | 'getPatronId' 49 | | 'patron' 50 | > 51 | ): HoldEntity { 52 | return Object.assign(new HoldEntity(), data); 53 | } 54 | 55 | getLibraryBranchId(): LibraryBranchId { 56 | return new LibraryBranchId(this.libraryBranchId); 57 | } 58 | 59 | getBookId(): BookId { 60 | return new BookId(this.bookId); 61 | } 62 | 63 | getPatronId(): PatronId { 64 | return new PatronId(this.patronId); 65 | } 66 | 67 | is( 68 | patronId: PatronId, 69 | bookId: BookId, 70 | libraryBranchId: LibraryBranchId 71 | ): boolean { 72 | return ( 73 | this.getPatronId().equals(patronId) && 74 | this.getBookId().equals(bookId) && 75 | this.getLibraryBranchId().equals(libraryBranchId) 76 | ); 77 | } 78 | 79 | toHoldModel(): Hold { 80 | return new Hold(this.getBookId(), this.getLibraryBranchId()); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /libs/lending/domain/tests/patron-requestion-last-passible-hold.spec.ts: -------------------------------------------------------------------------------- 1 | import { Either } from 'fp-ts/lib/Either'; 2 | import { AvailableBook } from '../src/lib/book/available-book'; 3 | import { BookHoldFailed } from '../src/lib/events/book-hold-failed'; 4 | import { BookPlacedOnHoldEvents } from '../src/lib/events/book-placed-on-hold-events'; 5 | import { MaximumNumberOhHoldsReached } from '../src/lib/events/maximum-number-on-holds-reached'; 6 | import { Patron } from '../src/lib/patron'; 7 | import { HoldDuration } from '../src/lib/value-objects/hold-duration'; 8 | import { NumberOfDays } from '../src/lib/value-objects/number-of-days'; 9 | import { PatronFixtures } from './patron.fixtures'; 10 | 11 | class Fixtures { 12 | static GivenRegularPatronWithLastPossibleHold(): Patron { 13 | return PatronFixtures.regularPatronWithHolds(4); 14 | } 15 | static GivenCirculatingAvailableBook = 16 | PatronFixtures.GivenCirculatingAvailableBook; 17 | static ThenAnnounceLasPossibleHold( 18 | result: Either 19 | ) { 20 | expect(result).toMatchObject( 21 | expect.objectContaining({ 22 | right: expect.objectContaining({ 23 | maximumNumberOhHoldsReached: new MaximumNumberOhHoldsReached(), 24 | }), 25 | }) 26 | ); 27 | } 28 | static WhenRequestingLastPossibleHold( 29 | patron: Patron, 30 | book: AvailableBook 31 | ): Either { 32 | return patron.placeOnCloseEndedHold( 33 | book, 34 | HoldDuration.closeEnded(NumberOfDays.of(3)) 35 | ); 36 | } 37 | } 38 | 39 | it('should announce that a regular patron places his last possible hold (4th)', async () => { 40 | const book = Fixtures.GivenCirculatingAvailableBook(); 41 | const patron = Fixtures.GivenRegularPatronWithLastPossibleHold(); 42 | const result = Fixtures.WhenRequestingLastPossibleHold(patron, book); 43 | Fixtures.ThenAnnounceLasPossibleHold(result); 44 | }); 45 | -------------------------------------------------------------------------------- /libs/lending/domain/tests/patron-requesting-open-ended-hold.spec.ts: -------------------------------------------------------------------------------- 1 | import { Either, right } from 'fp-ts/lib/Either'; 2 | import { AvailableBook } from '../src/lib/book/available-book'; 3 | import { BookHoldFailed } from '../src/lib/events/book-hold-failed'; 4 | import { BookPlacedOnHold } from '../src/lib/events/book-placed-on-hold'; 5 | import { BookPlacedOnHoldEvents } from '../src/lib/events/book-placed-on-hold-events'; 6 | import { Patron } from '../src/lib/patron'; 7 | import { PatronFixtures } from './patron.fixtures'; 8 | 9 | class Fixtures { 10 | ThenCantDoThis(result: Either) { 11 | expect(result).toMatchObject({ _tag: 'Left' }); // @ToDo 12 | } 13 | ThenTheBookIsOnHold( 14 | result: Either 15 | ): void { 16 | expect(result).toMatchObject( 17 | right( 18 | expect.objectContaining({ 19 | bookPlacedOnHold: expect.any(BookPlacedOnHold), 20 | }) 21 | ) 22 | ); 23 | } 24 | WhenRequestOpenEndedHold( 25 | patron: Patron, 26 | book: AvailableBook 27 | ): Either { 28 | return patron.placeOnOpenEndedHold(book); 29 | } 30 | } 31 | describe('PatronRequestingOpenEndedHold', () => { 32 | const fixtures = new Fixtures(); 33 | it('researcher patron can request close ended hold', () => { 34 | const book = PatronFixtures.GivenCirculatingAvailableBook(); 35 | const researcherPatron = PatronFixtures.GivenResearcherPatron(); 36 | const result = fixtures.WhenRequestOpenEndedHold(researcherPatron, book); 37 | fixtures.ThenTheBookIsOnHold(result); 38 | }); 39 | 40 | it('regular patron cannot request open ended hold', () => { 41 | const book = PatronFixtures.GivenCirculatingAvailableBook(); 42 | const regularPatron = PatronFixtures.GivenRegularPatron(); 43 | const result = fixtures.WhenRequestOpenEndedHold(regularPatron, book); 44 | fixtures.ThenCantDoThis(result); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "library", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "nx": "nx", 7 | "start": "nx serve", 8 | "build": "nx build", 9 | "test": "nx test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@mikro-orm/core": "5.5.3", 14 | "@mikro-orm/migrations": "5.5.3", 15 | "@mikro-orm/nestjs": "5.1.2", 16 | "@mikro-orm/postgresql": "5.5.3", 17 | "@nestjs-architects/typed-cqrs": "1.0.0", 18 | "@nestjs/common": "^8.0.0", 19 | "@nestjs/config": "^1.2.0", 20 | "@nestjs/core": "^8.0.0", 21 | "@nestjs/cqrs": "^8.0.1", 22 | "@nestjs/mapped-types": "*", 23 | "@nestjs/platform-express": "^8.0.0", 24 | "@nestjs/typeorm": "^8.0.3", 25 | "class-transformer": "^0.5.1", 26 | "class-validator": "^0.13.2", 27 | "fp-ts": "^2.11.5", 28 | "pg": "^8.7.3", 29 | "reflect-metadata": "^0.1.13", 30 | "rxjs": "^7.0.0", 31 | "tiny-types": "^1.16.1", 32 | "tslib": "^2.0.0", 33 | "typeorm": "^0.2.45" 34 | }, 35 | "devDependencies": { 36 | "@mikro-orm/cli": "5.5.3", 37 | "@nestjs-architects/nx-ddd-plugin": "^2.0.0", 38 | "@nestjs/schematics": "^8.0.0", 39 | "@nestjs/testing": "^8.0.0", 40 | "@nrwl/cli": "13.2.2", 41 | "@nrwl/eslint-plugin-nx": "13.2.2", 42 | "@nrwl/jest": "13.2.2", 43 | "@nrwl/linter": "13.2.2", 44 | "@nrwl/nest": "13.2.2", 45 | "@nrwl/node": "13.2.2", 46 | "@nrwl/tao": "13.2.2", 47 | "@nrwl/workspace": "13.2.2", 48 | "@sikora00/nestjs": "^0.3.1", 49 | "@types/jest": "27.0.2", 50 | "@types/node": "16.11.14", 51 | "@types/supertest": "^2.0.11", 52 | "@typescript-eslint/eslint-plugin": "~4.33.0", 53 | "@typescript-eslint/parser": "~4.33.0", 54 | "eslint": "7.32.0", 55 | "eslint-config-prettier": "8.1.0", 56 | "jest": "27.2.3", 57 | "jest-createspyobj": "^2.0.0", 58 | "prettier": "^2.3.1", 59 | "supertest": "^6.2.2", 60 | "ts-jest": "27.0.5", 61 | "typescript": "~4.4.3" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /libs/catalogue/src/lib/catalogue.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvents, Result } from '@library/shared/domain'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { pipe } from 'fp-ts/function'; 4 | import { getOrElse, map } from 'fp-ts/Option'; 5 | import { Book } from './book'; 6 | import { BookInstance } from './book-instance'; 7 | import { BookInstanceAddedToCatalogue } from './book-instance-added-to-catalogue'; 8 | import { BookType } from './book-type'; 9 | import { CatalogueDatabase } from './catalogue-database'; 10 | import { UpdateBookDto } from './dto/update-book.dto'; 11 | import { ISBN } from './isbn'; 12 | 13 | @Injectable() 14 | export class Catalogue { 15 | constructor( 16 | private readonly database: CatalogueDatabase, 17 | private readonly domainEvents: DomainEvents 18 | ) {} 19 | async addBook(author: string, title: string, isbn: string): Promise { 20 | const book = Book.create(isbn, author, title); 21 | await this.database.saveNewBook(book); 22 | return Result.Success; 23 | } 24 | 25 | async addBookInstance(isbn: string, bookType: BookType): Promise { 26 | return pipe( 27 | await this.database.findBookByIsbn(new ISBN(isbn)), 28 | map((book) => book.toAnInstance(bookType)), 29 | map(this.saveAndPublishEvents.bind(this)), 30 | map(() => Result.Success), 31 | getOrElse(() => Result.Rejection) 32 | ); 33 | } 34 | 35 | findAll() { 36 | return `This action returns all book`; 37 | } 38 | 39 | findOne(id: number) { 40 | return `This action returns a #${id} book`; 41 | } 42 | 43 | update(id: number, updateBookDto: UpdateBookDto) { 44 | return `This action updates a #${id} book`; 45 | } 46 | 47 | remove(id: number) { 48 | return `This action removes a #${id} book`; 49 | } 50 | 51 | private async saveAndPublishEvents( 52 | bookInstance: BookInstance 53 | ): Promise { 54 | await this.database.saveNewBookInstance(bookInstance); 55 | this.domainEvents.publish( 56 | BookInstanceAddedToCatalogue.fromBookInstance(bookInstance) 57 | ); 58 | return bookInstance; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/src/lib/typeorm/repositories/patron.repository.ts: -------------------------------------------------------------------------------- 1 | import { PatronRepository } from '@library/lending/application'; 2 | import { 3 | BookId, 4 | LibraryBranchId, 5 | Patron, 6 | PatronEvent, 7 | PatronFactory, 8 | PatronId, 9 | } from '@library/lending/domain'; 10 | import { DomainEvents } from '@library/shared/domain'; 11 | import { Injectable } from '@nestjs/common'; 12 | import { InjectRepository } from '@nestjs/typeorm'; 13 | import { option } from 'fp-ts'; 14 | import { pipe } from 'fp-ts/lib/function'; 15 | import { Option } from 'fp-ts/lib/Option'; 16 | import { Repository } from 'typeorm'; 17 | import { PatronEntity } from '../entities/patron.entity'; 18 | 19 | @Injectable() 20 | export class DomainModelMapper { 21 | constructor(private readonly patronFactory: PatronFactory) {} 22 | 23 | map(entity: PatronEntity): Patron { 24 | return this.patronFactory.create( 25 | entity.patronType, 26 | entity.patronId, 27 | this.mapPatronHolds(entity) 28 | ); 29 | } 30 | 31 | mapPatronHolds(patronEntity: PatronEntity): Set<[BookId, LibraryBranchId]> { 32 | return new Set( 33 | patronEntity.booksOnHold.map((entity) => [ 34 | entity.getBookId(), 35 | entity.getLibraryBranchId(), 36 | ]) 37 | ); 38 | } 39 | } 40 | 41 | @Injectable() 42 | export class PatronRepo implements PatronRepository { 43 | constructor( 44 | @InjectRepository(PatronEntity) 45 | private readonly typeormRepo: Repository, 46 | private readonly domainEvents: DomainEvents, 47 | private readonly domainModelMapper: DomainModelMapper 48 | ) {} 49 | 50 | publish(event: PatronEvent): Promise { 51 | const result = this.handleNextEvent(event); 52 | this.domainEvents.publish(event); 53 | return result; 54 | } 55 | 56 | async findById(id: PatronId): Promise> { 57 | const patron = await this.typeormRepo.findOne(id.value); 58 | return pipe( 59 | option.fromNullable(patron), 60 | option.map((patron) => this.domainModelMapper.map(patron)) 61 | ); 62 | } 63 | 64 | private async handleNextEvent(event: PatronEvent): Promise { 65 | let entity = await this.typeormRepo.findOneOrFail(event.patronId.value); 66 | entity = entity.handle(event); 67 | await this.typeormRepo.save(entity); 68 | return this.domainModelMapper.map(entity); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/book-placed-on-hold.event-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AvailableBook, 3 | Book, 4 | BookDuplicateHoldFound, 5 | BookOnHold, 6 | BookPlacedOnHold, 7 | BookPlacedOnHoldEvents, 8 | } from '@library/lending/domain'; 9 | import { DomainEvents } from '@library/shared/domain'; 10 | import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; 11 | import { pipe } from 'fp-ts/lib/function'; 12 | import { map } from 'fp-ts/lib/Option'; 13 | import { BookRepository } from './ports/book.repository'; 14 | 15 | @EventsHandler(BookPlacedOnHold, BookPlacedOnHoldEvents) 16 | export class BookPlacedOnHoldEventHandler 17 | implements IEventHandler 18 | { 19 | constructor( 20 | private readonly bookRepository: BookRepository, 21 | private readonly domainEvents: DomainEvents 22 | ) {} 23 | 24 | async handle( 25 | event: BookPlacedOnHold | BookPlacedOnHoldEvents 26 | ): Promise { 27 | let bookPlacedOnHold: BookPlacedOnHold; 28 | if (event instanceof BookPlacedOnHoldEvents) { 29 | bookPlacedOnHold = event.bookPlacedOnHold; 30 | } else { 31 | bookPlacedOnHold = event; 32 | } 33 | const maybeBook = await this.bookRepository.findById( 34 | bookPlacedOnHold.bookId 35 | ); 36 | pipe( 37 | maybeBook, 38 | map((book) => this.handleBookPlacedOnHold(book, bookPlacedOnHold)), 39 | map(this.saveBook.bind(this)) 40 | ); 41 | } 42 | 43 | private handleBookPlacedOnHold( 44 | book: Book, 45 | bookPlacedOnHold: BookPlacedOnHold 46 | ): Book { 47 | switch (book.constructor) { 48 | case AvailableBook: 49 | return (book as AvailableBook).handleBookPlacedOnHold(bookPlacedOnHold); 50 | case BookOnHold: 51 | return this.raiseDuplicateHoldFoundEvent( 52 | book as BookOnHold, 53 | bookPlacedOnHold 54 | ); 55 | default: 56 | return book; 57 | } 58 | } 59 | 60 | private raiseDuplicateHoldFoundEvent( 61 | onHold: BookOnHold, 62 | bookPlacedOnHold: BookPlacedOnHold 63 | ): BookOnHold { 64 | if (onHold.by(bookPlacedOnHold.patronId)) { 65 | return onHold; 66 | } 67 | this.domainEvents.publish( 68 | new BookDuplicateHoldFound(onHold.bookId, onHold.patronId) 69 | ); 70 | return onHold; 71 | } 72 | 73 | private async saveBook(book: Book): Promise { 74 | await this.bookRepository.save(book); 75 | return book; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/src/lib/typeorm/entities/book.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AvailableBook, 3 | Book, 4 | BookId, 5 | BookOnHold, 6 | LibraryBranchId, 7 | PatronId, 8 | } from '@library/lending/domain'; 9 | import { Version } from '@library/shared/domain'; 10 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 11 | 12 | export enum BookState { 13 | Available, 14 | OnHold, 15 | } 16 | @Entity() 17 | export class BookEntity { 18 | @PrimaryGeneratedColumn('uuid') 19 | bookId!: string; 20 | 21 | @Column('uuid', { nullable: true }) 22 | availableAtBranch!: string | null; 23 | 24 | @Column('uuid', { nullable: true }) 25 | onHoldAtBranch!: string | null; 26 | 27 | @Column('uuid', { nullable: true }) 28 | onHoldByPatron!: string | null; 29 | 30 | @Column({ type: 'enum', enum: BookState }) 31 | state!: BookState; 32 | 33 | @Column({ type: 'smallint' }) 34 | version!: number; 35 | 36 | getBookId(): BookId { 37 | return new BookId(this.bookId); 38 | } 39 | 40 | toDomainModel(): Book { 41 | let check: never; 42 | switch (this.state) { 43 | case BookState.Available: 44 | return this.toAvailableBook(); 45 | case BookState.OnHold: 46 | return this.toBookOnHold(); 47 | default: 48 | check = this.state; 49 | throw new Error( 50 | `Can't map book with state: ${this.state} to any model` 51 | ); 52 | } 53 | } 54 | 55 | toAvailableBook(): AvailableBook { 56 | if (!this.availableAtBranch) { 57 | throw new Error('availableAtBranch is empty'); 58 | } 59 | return new AvailableBook( 60 | this.getBookId(), 61 | new LibraryBranchId(this.availableAtBranch), 62 | new Version(this.version) 63 | ); 64 | } 65 | 66 | toBookOnHold(): BookOnHold { 67 | if (!this.onHoldAtBranch) { 68 | throw new Error('onHoldAtBranch is empty'); 69 | } 70 | if (!this.onHoldByPatron) { 71 | throw new Error('onHoldByPatron is empty'); 72 | } 73 | return new BookOnHold( 74 | this.getBookId(), 75 | new LibraryBranchId(this.onHoldAtBranch), 76 | new PatronId(this.onHoldByPatron), 77 | new Version(this.version) 78 | ); 79 | } 80 | 81 | static restore( 82 | data: Omit< 83 | BookEntity, 84 | | 'getBookId' 85 | | 'getLibraryBranchId' 86 | | 'toAvailableBook' 87 | | 'toDomainModel' 88 | | 'toBookOnHold' 89 | > 90 | ): BookEntity { 91 | return Object.assign(new BookEntity(), data); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/src/lib/typeorm/entities/patron.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BookHoldCanceled, 3 | BookId, 4 | BookPlacedOnHold, 5 | BookPlacedOnHoldEvents, 6 | LibraryBranchId, 7 | PatronEvent, 8 | PatronId, 9 | PatronType, 10 | } from '@library/lending/domain'; 11 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; 12 | import { HoldEntity } from './hold.entity'; 13 | 14 | @Entity() 15 | export class PatronEntity { 16 | @PrimaryGeneratedColumn('uuid') 17 | id!: string; 18 | 19 | @OneToMany(() => HoldEntity, (hold) => hold.patron, { 20 | eager: true, 21 | cascade: true, 22 | onDelete: 'CASCADE', 23 | }) 24 | booksOnHold!: HoldEntity[]; 25 | 26 | @Column({ type: 'enum', enum: PatronType }) 27 | patronType!: PatronType; 28 | 29 | get patronId(): PatronId { 30 | return new PatronId(this.id); 31 | } 32 | 33 | static restore( 34 | data: Omit< 35 | PatronEntity, 36 | | 'patronId' 37 | | 'handle' 38 | | 'handleBookPlacedOnHold' 39 | | 'handleBookHoldCanceled' 40 | > 41 | ): PatronEntity { 42 | return Object.assign(new PatronEntity(), data); 43 | } 44 | 45 | handle(event: PatronEvent): PatronEntity { 46 | if (event instanceof BookPlacedOnHold) { 47 | return this.handleBookPlacedOnHold(event); 48 | } 49 | 50 | if (event instanceof BookPlacedOnHoldEvents) { 51 | return this.handleBookPlacedOnHold(event.bookPlacedOnHold); 52 | } 53 | 54 | if (event instanceof BookHoldCanceled) { 55 | return this.handleBookHoldCanceled(event); 56 | } 57 | 58 | throw new Error(`No handler for event ${event.constructor.name}`); 59 | } 60 | handleBookHoldCanceled(event: BookHoldCanceled): PatronEntity { 61 | return this.removeHoldIfPresent( 62 | event.patronId, 63 | event.bookId, 64 | event.libraryBranchId 65 | ); 66 | } 67 | 68 | handleBookPlacedOnHold(event: BookPlacedOnHold): PatronEntity { 69 | this.booksOnHold.push( 70 | HoldEntity.create({ 71 | patronId: event.patronId.value, 72 | bookId: event.bookId.value, 73 | libraryBranchId: event.libraryBranchId.value, 74 | }) 75 | ); 76 | return this; 77 | } 78 | 79 | private removeHoldIfPresent( 80 | patronId: PatronId, 81 | bookId: BookId, 82 | libraryBranchId: LibraryBranchId 83 | ): PatronEntity { 84 | this.booksOnHold = this.booksOnHold.filter( 85 | (entity) => !entity.is(patronId, bookId, libraryBranchId) 86 | ); 87 | return this; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /libs/lending/domain/tests/patron.fixtures.ts: -------------------------------------------------------------------------------- 1 | import { Version } from '@library/shared/domain'; 2 | import { AvailableBook } from '../src/lib/book/available-book'; 3 | import { BookOnHold } from '../src/lib/book/book-on-hold'; 4 | import { Patron } from '../src/lib/patron'; 5 | import { 6 | allCurrentPolicies, 7 | onlyResearcherPatronsCanPlaceOpenEndedHolds, 8 | } from '../src/lib/policies/placing-on-hold-policy'; 9 | import { BookId } from '../src/lib/value-objects/book-id'; 10 | import { Hold } from '../src/lib/value-objects/hold'; 11 | import { LibraryBranchId } from '../src/lib/value-objects/library-branch-id'; 12 | import { PatronHolds } from '../src/lib/value-objects/patron-holds'; 13 | import { PatronId } from '../src/lib/value-objects/patron-id'; 14 | import { PatronInformation } from '../src/lib/value-objects/patron-information'; 15 | import { PatronType } from '../src/lib/value-objects/patron-type'; 16 | 17 | export class PatronFixtures { 18 | static regularPatronWithHold( 19 | bookOnHold: BookOnHold, 20 | patronId?: PatronId 21 | ): Patron { 22 | return new Patron( 23 | new PatronHolds( 24 | new Set([new Hold(bookOnHold.bookId, bookOnHold.libraryBranchId)]) 25 | ), 26 | new Set([onlyResearcherPatronsCanPlaceOpenEndedHolds]), 27 | new PatronInformation( 28 | patronId ?? PatronId.generate(), 29 | PatronType.Researcher 30 | ) 31 | ); 32 | } 33 | 34 | static GivenRegularPatron(patronId?: PatronId): Patron { 35 | if (!patronId) { 36 | patronId = PatronId.generate(); 37 | } 38 | return new Patron( 39 | new PatronHolds(new Set()), 40 | new Set([onlyResearcherPatronsCanPlaceOpenEndedHolds]), 41 | new PatronInformation(patronId, PatronType.Regular) 42 | ); 43 | } 44 | static GivenCirculatingAvailableBook(): AvailableBook { 45 | return new AvailableBook( 46 | BookId.generate(), 47 | LibraryBranchId.generate(), 48 | Version.zero() 49 | ); 50 | } 51 | static GivenResearcherPatron(): Patron { 52 | return new Patron( 53 | new PatronHolds(new Set()), 54 | new Set([onlyResearcherPatronsCanPlaceOpenEndedHolds]), 55 | new PatronInformation(PatronId.generate(), PatronType.Researcher) 56 | ); 57 | } 58 | 59 | static regularPatronWithHolds(numberOfHold: number): Patron { 60 | return new Patron( 61 | new PatronHolds( 62 | new Set( 63 | Array(numberOfHold) 64 | .fill(null) 65 | .map(() => new Hold(BookId.generate(), LibraryBranchId.generate())) 66 | ) 67 | ), 68 | allCurrentPolicies, 69 | new PatronInformation(PatronId.generate(), PatronType.Regular) 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/place-on-hold/place-on-hold.handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AvailableBook, 3 | BookHoldFailed, 4 | BookId, 5 | BookPlacedOnHoldEvents, 6 | Patron, 7 | PatronId, 8 | } from '@library/lending/domain'; 9 | import { InvalidArgumentException, Result } from '@library/shared/domain'; 10 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; 11 | import { match } from 'fp-ts/Either'; 12 | import { pipe } from 'fp-ts/function'; 13 | import { getOrElseW } from 'fp-ts/Option'; 14 | import { PatronRepository } from '../ports/patron.repository'; 15 | import { FindAvailableBook } from './find-available-book'; 16 | import { PlaceOnHoldCommand } from './place-on-hold.command'; 17 | 18 | @CommandHandler(PlaceOnHoldCommand) 19 | export class PlaceOnHoldHandler implements ICommandHandler { 20 | constructor( 21 | private readonly findAvailableBook: FindAvailableBook, 22 | private readonly repository: PatronRepository 23 | ) {} 24 | 25 | async execute(command: PlaceOnHoldCommand): Promise { 26 | const availableBook = await this.findBook(command.bookId); 27 | const patron = await this.findPatron(command.patron); 28 | const result = patron.placeOnHold(availableBook, command.holdDuration); 29 | return pipe( 30 | result, 31 | match>( 32 | this.publishOnFail.bind(this), 33 | this.publishOnSuccess.bind(this) 34 | ) 35 | ); 36 | } 37 | 38 | private findBook(id: BookId): Promise { 39 | return this.findAvailableBook.findAvailableBookById(id).then((result) => 40 | pipe( 41 | result, 42 | getOrElseW(() => { 43 | throw new InvalidArgumentException( 44 | `Cannot find available book with Id: ${id}` 45 | ); 46 | }) 47 | ) 48 | ); 49 | } 50 | 51 | private findPatron(patronId: PatronId): Promise { 52 | return this.repository.findById(patronId).then((result) => 53 | pipe( 54 | result, 55 | getOrElseW(() => { 56 | throw new InvalidArgumentException( 57 | `Patron with given Id does not exists: : ${patronId}` 58 | ); 59 | }) 60 | ) 61 | ); 62 | } 63 | 64 | private async publishOnFail( 65 | bookHoldFailed: BookHoldFailed 66 | ): Promise { 67 | await this.repository.publish(bookHoldFailed); 68 | 69 | return Result.Rejection; 70 | } 71 | 72 | private async publishOnSuccess( 73 | placedOnHold: BookPlacedOnHoldEvents 74 | ): Promise { 75 | await this.repository.publish(placedOnHold); 76 | 77 | return Result.Success; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/cancel-hold/cancel-hold.handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BookHoldCanceled, 3 | BookHoldCancelingFailed, 4 | BookId, 5 | BookOnHold, 6 | Patron, 7 | PatronId, 8 | } from '@library/lending/domain'; 9 | import { InvalidArgumentException, Result } from '@library/shared/domain'; 10 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; 11 | import { match } from 'fp-ts/Either'; 12 | import { pipe } from 'fp-ts/function'; 13 | import { getOrElseW } from 'fp-ts/Option'; 14 | import { PatronRepository } from '../ports/patron.repository'; 15 | import { CancelHoldCommand } from './cancel-hold.command'; 16 | import { FindBookOnHold } from './find-book-on-hold'; 17 | 18 | @CommandHandler(CancelHoldCommand) 19 | export class CancelHoldHandler implements ICommandHandler { 20 | constructor( 21 | private readonly findBookOnHold: FindBookOnHold, 22 | private readonly patronRepository: PatronRepository 23 | ) {} 24 | async execute(command: CancelHoldCommand): Promise { 25 | const bookOnHold = await this.findBook(command.bookId, command.patronId); 26 | const patron = await this.findPatron(command.patronId); 27 | const result = patron.cancelHold(bookOnHold); 28 | return pipe( 29 | result, 30 | match>( 31 | this.publishOnFail.bind(this), 32 | this.publishOnSuccess.bind(this) 33 | ) 34 | ); 35 | } 36 | 37 | private findBook(bookId: BookId, patronId: PatronId): Promise { 38 | return this.findBookOnHold.findBookOnHold(bookId, patronId).then((result) => 39 | pipe( 40 | result, 41 | getOrElseW(() => { 42 | throw new InvalidArgumentException( 43 | `Cannot find book on hold with Id: ${bookId}` 44 | ); 45 | }) 46 | ) 47 | ); 48 | } 49 | 50 | private findPatron(patronId: PatronId): Promise { 51 | return this.patronRepository.findById(patronId).then((result) => 52 | pipe( 53 | result, 54 | getOrElseW(() => { 55 | throw new InvalidArgumentException( 56 | `Patron with given Id does not exists: : ${patronId}` 57 | ); 58 | }) 59 | ) 60 | ); 61 | } 62 | 63 | private async publishOnFail( 64 | bookCancelingFailed: BookHoldCancelingFailed 65 | ): Promise { 66 | await this.patronRepository.publish(bookCancelingFailed); 67 | 68 | return Result.Rejection; 69 | } 70 | 71 | private async publishOnSuccess( 72 | bookHoldCanceled: BookHoldCanceled 73 | ): Promise { 74 | await this.patronRepository.publish(bookHoldCanceled); 75 | 76 | return Result.Success; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /apps/library/test/smoke/cancel-hold.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { getRepositoryToken } from '@nestjs/typeorm'; 4 | import { PatronType } from 'libs/lending/domain/src/lib/value-objects/patron-type'; 5 | import { 6 | BookEntity, 7 | BookState, 8 | } from 'libs/lending/infrastructure/src/lib/typeorm/entities/book.entity'; 9 | import { HoldEntity } from 'libs/lending/infrastructure/src/lib/typeorm/entities/hold.entity'; 10 | import { PatronEntity } from 'libs/lending/infrastructure/src/lib/typeorm/entities/patron.entity'; 11 | import * as request from 'supertest'; 12 | import { Repository } from 'typeorm'; 13 | import { AppModule } from '../../src/app/app.module'; 14 | 15 | describe('Take book on hold', () => { 16 | let app: INestApplication; 17 | 18 | beforeAll(async () => { 19 | const moduleRef = await Test.createTestingModule({ 20 | imports: [AppModule], 21 | }).compile(); 22 | 23 | app = moduleRef.createNestApplication(); 24 | await app.init(); 25 | }); 26 | 27 | afterAll(async () => { 28 | await app.close(); 29 | }); 30 | 31 | describe('Given patron', () => { 32 | describe('And his book on hold', () => { 33 | let patronRepo: Repository; 34 | let bookRepo: Repository; 35 | const patronId = '55760e4e-9aa9-4754-ae26-159df2fd03da'; 36 | const bookId = '55760e4e-9aa9-4754-ae26-159df2fd03da'; 37 | const libraryBranchId = '55760e4e-9aa9-4754-ae26-159df2fd03dc'; 38 | beforeAll(async () => { 39 | bookRepo = app.get(getRepositoryToken(BookEntity)); 40 | await bookRepo.insert( 41 | BookEntity.restore({ 42 | bookId, 43 | availableAtBranch: null, 44 | onHoldAtBranch: libraryBranchId, 45 | state: BookState.OnHold, 46 | onHoldByPatron: patronId, 47 | version: 1, 48 | }) 49 | ); 50 | patronRepo = app.get(getRepositoryToken(PatronEntity)); 51 | await patronRepo.save( 52 | patronRepo.create( 53 | PatronEntity.restore({ 54 | id: patronId, 55 | booksOnHold: [ 56 | HoldEntity.create({ bookId, libraryBranchId, patronId }), 57 | ], 58 | patronType: PatronType.Regular, 59 | }) 60 | ) 61 | ); 62 | }); 63 | 64 | afterAll(async () => { 65 | await patronRepo.delete(patronId); 66 | await bookRepo.delete(bookId); 67 | }); 68 | 69 | it(`/DELETE /profiles/:profileId/holds/:bookId`, () => { 70 | return request(app.getHttpServer()) 71 | .delete(`/profiles/${patronId}/holds/${bookId}`) 72 | .expect(204); 73 | }); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/check-out/check-out-book.handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BookCheckedOut, 3 | BookCheckOutFailed, 4 | BookId, 5 | BookOnHold, 6 | Patron, 7 | PatronId, 8 | } from '@library/lending/domain'; 9 | import { InvalidArgumentException, Result } from '@library/shared/domain'; 10 | import { IInferredCommandHandler } from '@nestjs-architects/typed-cqrs'; 11 | import { CommandHandler } from '@nestjs/cqrs'; 12 | import { match } from 'fp-ts/Either'; 13 | import { pipe } from 'fp-ts/function'; 14 | import { getOrElseW } from 'fp-ts/Option'; 15 | import { FindBookOnHold } from '../cancel-hold/find-book-on-hold'; 16 | import { PatronRepository } from '../ports/patron.repository'; 17 | import { CheckOutBookCommand } from './check-out-book.command'; 18 | 19 | @CommandHandler(CheckOutBookCommand) 20 | export class CheckOutBookHandler 21 | implements IInferredCommandHandler 22 | { 23 | constructor( 24 | private readonly findBookOnHold: FindBookOnHold, 25 | private readonly patronRepository: PatronRepository 26 | ) {} 27 | 28 | async execute(command: CheckOutBookCommand): Promise { 29 | const book = await this.findBook(command.bookId, command.patronId); 30 | const patron = await this.findPatron(command.patronId); 31 | const result = patron.checkoutBook(book); 32 | return pipe( 33 | result, 34 | match>( 35 | this.publishOnFail.bind(this), 36 | this.publishOnSuccess.bind(this) 37 | ) 38 | ); 39 | } 40 | 41 | private findBook(id: BookId, patronId: PatronId): Promise { 42 | return this.findBookOnHold.findBookOnHold(id, patronId).then((result) => 43 | pipe( 44 | result, 45 | getOrElseW(() => { 46 | throw new InvalidArgumentException( 47 | `Cannot find available book with Id: ${id}` 48 | ); 49 | }) 50 | ) 51 | ); 52 | } 53 | 54 | private findPatron(patronId: PatronId): Promise { 55 | return this.patronRepository.findById(patronId).then((result) => 56 | pipe( 57 | result, 58 | getOrElseW(() => { 59 | throw new InvalidArgumentException( 60 | `Patron with given Id does not exists: ${patronId}` 61 | ); 62 | }) 63 | ) 64 | ); 65 | } 66 | 67 | private async publishOnFail( 68 | bookCheckOutFailed: BookCheckOutFailed 69 | ): Promise { 70 | await this.patronRepository.publish(bookCheckOutFailed); 71 | 72 | return Result.Rejection; 73 | } 74 | 75 | private async publishOnSuccess( 76 | bookCheckedOut: BookCheckedOut 77 | ): Promise { 78 | await this.patronRepository.publish(bookCheckedOut); 79 | 80 | return Result.Success; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /apps/library/src/migrations/.snapshot-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "namespaces": [ 3 | "public" 4 | ], 5 | "name": "public", 6 | "tables": [ 7 | { 8 | "columns": { 9 | "isbn": { 10 | "name": "isbn", 11 | "type": "varchar(100)", 12 | "unsigned": false, 13 | "autoincrement": false, 14 | "primary": false, 15 | "nullable": false, 16 | "mappedType": "string" 17 | }, 18 | "author": { 19 | "name": "author", 20 | "type": "varchar(100)", 21 | "unsigned": false, 22 | "autoincrement": false, 23 | "primary": false, 24 | "nullable": false, 25 | "mappedType": "string" 26 | }, 27 | "title": { 28 | "name": "title", 29 | "type": "varchar(100)", 30 | "unsigned": false, 31 | "autoincrement": false, 32 | "primary": false, 33 | "nullable": false, 34 | "mappedType": "string" 35 | } 36 | }, 37 | "name": "book", 38 | "schema": "public", 39 | "indexes": [ 40 | { 41 | "keyName": "book_pkey", 42 | "columnNames": [ 43 | "isbn" 44 | ], 45 | "composite": false, 46 | "primary": true, 47 | "unique": true 48 | } 49 | ], 50 | "checks": [], 51 | "foreignKeys": {} 52 | }, 53 | { 54 | "columns": { 55 | "book_id": { 56 | "name": "book_id", 57 | "type": "varchar(255)", 58 | "unsigned": false, 59 | "autoincrement": false, 60 | "primary": false, 61 | "nullable": false, 62 | "mappedType": "string" 63 | }, 64 | "isbn": { 65 | "name": "isbn", 66 | "type": "varchar(100)", 67 | "unsigned": false, 68 | "autoincrement": false, 69 | "primary": false, 70 | "nullable": false, 71 | "mappedType": "string" 72 | }, 73 | "book_type": { 74 | "name": "book_type", 75 | "type": "smallint", 76 | "unsigned": false, 77 | "autoincrement": false, 78 | "primary": false, 79 | "nullable": false, 80 | "mappedType": "enum" 81 | } 82 | }, 83 | "name": "book_instance", 84 | "schema": "public", 85 | "indexes": [ 86 | { 87 | "keyName": "book_instance_pkey", 88 | "columnNames": [ 89 | "book_id" 90 | ], 91 | "composite": false, 92 | "primary": true, 93 | "unique": true 94 | } 95 | ], 96 | "checks": [], 97 | "foreignKeys": {} 98 | } 99 | ] 100 | } 101 | -------------------------------------------------------------------------------- /apps/library/test/smoke/take-book-on-hold.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { getRepositoryToken } from '@nestjs/typeorm'; 4 | import { PatronType } from 'libs/lending/domain/src/lib/value-objects/patron-type'; 5 | import { 6 | BookEntity, 7 | BookState, 8 | } from 'libs/lending/infrastructure/src/lib/typeorm/entities/book.entity'; 9 | import { HoldEntity } from 'libs/lending/infrastructure/src/lib/typeorm/entities/hold.entity'; 10 | import { PatronEntity } from 'libs/lending/infrastructure/src/lib/typeorm/entities/patron.entity'; 11 | import * as request from 'supertest'; 12 | import { Repository } from 'typeorm'; 13 | import { AppModule } from '../../src/app/app.module'; 14 | 15 | describe('Take book on hold', () => { 16 | let app: INestApplication; 17 | 18 | beforeAll(async () => { 19 | const moduleRef = await Test.createTestingModule({ 20 | imports: [AppModule], 21 | }).compile(); 22 | 23 | app = moduleRef.createNestApplication(); 24 | await app.init(); 25 | }); 26 | 27 | afterAll(async () => { 28 | await app.close(); 29 | }); 30 | 31 | describe('Given patron', () => { 32 | let patronRepo: Repository; 33 | const patronId = '55760e4e-9aa9-4754-ae26-159df2fd03dd'; 34 | beforeAll(async () => { 35 | patronRepo = app.get(getRepositoryToken(PatronEntity)); 36 | await patronRepo.insert( 37 | PatronEntity.restore({ 38 | id: patronId, 39 | booksOnHold: [], 40 | patronType: PatronType.Regular, 41 | }) 42 | ); 43 | }); 44 | 45 | afterAll(async () => { 46 | await patronRepo.delete(patronId); 47 | }); 48 | 49 | describe('And available book', () => { 50 | let bookRepo: Repository; 51 | const bookId = '55760e4e-9aa9-4754-ae26-159df2fd03dd'; 52 | const libraryBranchId = '55760e4e-9aa9-4754-ae26-159df2fd03dd'; 53 | 54 | beforeAll(async () => { 55 | bookRepo = app.get(getRepositoryToken(BookEntity)); 56 | await bookRepo.insert( 57 | BookEntity.restore({ 58 | bookId, 59 | availableAtBranch: libraryBranchId, 60 | onHoldAtBranch: null, 61 | state: BookState.Available, 62 | onHoldByPatron: null, 63 | version: 0, 64 | }) 65 | ); 66 | }); 67 | 68 | afterAll(async () => { 69 | await bookRepo.delete(bookId); 70 | app.get(getRepositoryToken(HoldEntity)).delete({ patronId }); 71 | }); 72 | 73 | it(`/POST /profiles/:patronId/holds`, () => { 74 | return request(app.getHttpServer()) 75 | .post(`/profiles/${patronId}/holds`) 76 | .send({ 77 | bookId, 78 | numberOfDays: 2, 79 | }) 80 | .expect(201); 81 | }); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /libs/lending/domain/tests/patron-requesting-close-ended-hold.spec.ts: -------------------------------------------------------------------------------- 1 | import { right } from 'fp-ts/Either'; 2 | import { Either } from 'fp-ts/lib/Either'; 3 | import { AvailableBook } from '../src/lib/book/available-book'; 4 | import { BookHoldFailed } from '../src/lib/events/book-hold-failed'; 5 | import { BookPlacedOnHoldEvents } from '../src/lib/events/book-placed-on-hold-events'; 6 | import { Patron } from '../src/lib/patron'; 7 | import { DateVO } from '../src/lib/value-objects/date.vo'; 8 | import { HoldDuration } from '../src/lib/value-objects/hold-duration'; 9 | import { NumberOfDays } from '../src/lib/value-objects/number-of-days'; 10 | import { PatronFixtures } from './patron.fixtures'; 11 | 12 | class Fixtures { 13 | private constructor() { 14 | // use init 15 | } 16 | static init(): Fixtures { 17 | jest.useFakeTimers().setSystemTime(new Date('2021-01-01').getTime()); 18 | return new Fixtures(); 19 | } 20 | GivenPatronWithManyHolds(): Patron { 21 | return PatronFixtures.regularPatronWithHolds(5); 22 | } 23 | 24 | GivenAnyPatron(): Patron[] { 25 | return [ 26 | PatronFixtures.GivenRegularPatron(), 27 | PatronFixtures.GivenResearcherPatron(), 28 | ]; 29 | } 30 | 31 | GivenCirculatingAvailableBook = 32 | PatronFixtures.GivenCirculatingAvailableBook.bind(this); 33 | 34 | ThenBookShouldBePlacedOnHoldTillDate( 35 | result: Either 36 | ): void { 37 | expect(result).toMatchObject( 38 | right( 39 | expect.objectContaining({ 40 | bookPlacedOnHold: expect.objectContaining({ 41 | till: DateVO.now().addDays(3), 42 | }), 43 | }) 44 | ) 45 | ); 46 | } 47 | 48 | ThenItFailed(result: Either): void { 49 | expect(result).toMatchObject({ _tag: 'Left' }); // @ToDo 50 | } 51 | 52 | WhenRequestingCloseEndedHold( 53 | patron: Patron, 54 | book: AvailableBook, 55 | duration: HoldDuration 56 | ): Either { 57 | return patron.placeOnCloseEndedHold(book, duration); 58 | } 59 | } 60 | 61 | describe('PatronRequestingCloseEndedHold', () => { 62 | const fixtures = Fixtures.init(); 63 | test('any patron can request close ended hold', () => { 64 | fixtures.GivenAnyPatron().forEach((patron) => { 65 | const book = fixtures.GivenCirculatingAvailableBook(); 66 | const result = fixtures.WhenRequestingCloseEndedHold( 67 | patron, 68 | book, 69 | HoldDuration.closeEnded(NumberOfDays.of(3)) 70 | ); 71 | fixtures.ThenBookShouldBePlacedOnHoldTillDate(result); 72 | }); 73 | }); 74 | 75 | test('patron cannot hold a book for 0 or negative amount of days', () => { 76 | for (let days = -10; days <= 0; days++) { 77 | const test = () => { 78 | const patron = PatronFixtures.GivenRegularPatron(); 79 | const book = fixtures.GivenCirculatingAvailableBook(); 80 | fixtures.WhenRequestingCloseEndedHold( 81 | patron, 82 | book, 83 | HoldDuration.closeEnded(NumberOfDays.of(days)) 84 | ); 85 | }; 86 | expect(test).toThrow(); 87 | } 88 | }); 89 | 90 | test('patron cannot hold more books than it is allowed', () => { 91 | const patron = fixtures.GivenPatronWithManyHolds(); 92 | const book = fixtures.GivenCirculatingAvailableBook(); 93 | const result = fixtures.WhenRequestingCloseEndedHold( 94 | patron, 95 | book, 96 | HoldDuration.closeEnded(NumberOfDays.of(1)) 97 | ); 98 | fixtures.ThenItFailed(result); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/cancel-hold/cancel-hold.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { BookId, PatronId } from '@library/lending/domain'; 2 | import { Result } from '@library/shared/domain'; 3 | import { option } from 'fp-ts'; 4 | import { createSpyObj } from 'jest-createspyobj'; 5 | // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries 6 | import { BookFixtures } from 'libs/lending/domain/tests/book.fixtures'; 7 | // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries 8 | import { PatronFixtures } from 'libs/lending/domain/tests/patron.fixtures'; 9 | import { PatronRepository } from '../ports/patron.repository'; 10 | import { CancelHoldCommand } from './cancel-hold.command'; 11 | import { CancelHoldHandler } from './cancel-hold.handler'; 12 | import { FindBookOnHold } from './find-book-on-hold'; 13 | 14 | describe('CancelHoldHandler', () => { 15 | const bookOnHold = BookFixtures.bookOnHold(); 16 | const patronId = PatronId.generate(); 17 | 18 | const willFindBook: FindBookOnHold = { 19 | findBookOnHold: () => Promise.resolve(option.of(bookOnHold)), 20 | }; 21 | const willNotFindBook: FindBookOnHold = { 22 | findBookOnHold: () => Promise.resolve(option.none), 23 | }; 24 | const repository = createSpyObj(PatronRepository, ['findById', 'publish']); 25 | 26 | afterEach(() => { 27 | repository.publish.mockReset(); 28 | }); 29 | 30 | it('should successfully cancel hold if book was placed on hold by patron, and patron and book exist', async () => { 31 | // given 32 | const canceling = new CancelHoldHandler(willFindBook, repository); 33 | // and 34 | persistedRegularPatronWithBookOnHold(); 35 | // when 36 | const result: Result = await canceling.execute(cmd()); 37 | // then 38 | expect(result).toBe(Result.Success); 39 | }); 40 | 41 | it('should reject placing on hold book if one of the domain rules is broken (but should not fail!)', async () => { 42 | // given 43 | const canceling = new CancelHoldHandler(willFindBook, repository); 44 | // and 45 | persistedRegularPatronWithoutBookOnHold(); 46 | // when 47 | const result = await canceling.execute(cmd()); 48 | // then 49 | expect(result).toBe(Result.Rejection); 50 | }); 51 | 52 | it('should fail if patron does not exists', async () => { 53 | // given 54 | const canceling = new CancelHoldHandler(willFindBook, repository); 55 | // and 56 | unknownPatron(); 57 | // when 58 | const result = canceling.execute(cmd()); 59 | // then 60 | await expect(result).rejects.toThrow(); 61 | }); 62 | 63 | it('should fail if book does not exists', async () => { 64 | // given 65 | const canceling = new CancelHoldHandler(willNotFindBook, repository); 66 | // and 67 | persistedRegularPatronWithBookOnHold(); 68 | // when 69 | const result = canceling.execute(cmd()); 70 | // then 71 | await expect(result).rejects.toThrow(); 72 | }); 73 | 74 | it('should fail if saving patron fails', async () => { 75 | // given 76 | const canceling = new CancelHoldHandler(willFindBook, repository); 77 | // and 78 | persistedRegularPatronThatFailsOnSaving(); 79 | // when 80 | const result = canceling.execute(cmd()); 81 | // then 82 | await expect(result).rejects.toThrow(); 83 | }); 84 | 85 | function cmd(): CancelHoldCommand { 86 | return new CancelHoldCommand(patronId, BookId.generate()); 87 | } 88 | 89 | function persistedRegularPatronWithBookOnHold(): PatronId { 90 | const patron = PatronFixtures.regularPatronWithHold(bookOnHold); 91 | repository.findById.mockResolvedValueOnce(option.of(patron)); 92 | repository.publish.mockResolvedValueOnce(patron); 93 | return patronId; 94 | } 95 | 96 | function persistedRegularPatronWithoutBookOnHold(): PatronId { 97 | const patron = PatronFixtures.regularPatronWithHolds(10); 98 | repository.findById.mockResolvedValueOnce(option.of(patron)); 99 | return patronId; 100 | } 101 | 102 | function persistedRegularPatronThatFailsOnSaving(): PatronId { 103 | const patron = PatronFixtures.regularPatronWithHold(bookOnHold); 104 | repository.findById.mockResolvedValueOnce(option.of(patron)); 105 | repository.publish.mockRejectedValueOnce(new Error('Mocked to fail')); 106 | return patronId; 107 | } 108 | 109 | function unknownPatron(): PatronId { 110 | return PatronId.generate(); 111 | } 112 | }); 113 | -------------------------------------------------------------------------------- /libs/lending/application/src/lib/check-out/check-out-book.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { BookId, BookOnHold, PatronId } from '@library/lending/domain'; 2 | import { Result } from '@library/shared/domain'; 3 | import { none, some } from 'fp-ts/Option'; 4 | import { createSpyObj } from 'jest-createspyobj'; 5 | // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries 6 | import { BookFixtures } from '../../../../domain/tests/book.fixtures'; 7 | // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries 8 | import { PatronFixtures } from '../../../../domain/tests/patron.fixtures'; 9 | import { FindBookOnHold } from '../cancel-hold/find-book-on-hold'; 10 | import { PatronRepository } from '../ports/patron.repository'; 11 | import { CheckOutBookCommand } from './check-out-book.command'; 12 | import { CheckOutBookHandler } from './check-out-book.handler'; 13 | 14 | describe('CheckOutBookHandler', () => { 15 | const bookOnHold = BookFixtures.bookOnHold(); 16 | 17 | const willFindBook: FindBookOnHold = { 18 | findBookOnHold: () => Promise.resolve(some(bookOnHold)), 19 | }; 20 | const willNotFindBook: FindBookOnHold = { 21 | findBookOnHold: () => Promise.resolve(none), 22 | }; 23 | const patronRepository: jest.Mocked = createSpyObj( 24 | PatronRepository, 25 | ['findById', 'publish'] 26 | ); 27 | 28 | it('should successfully check out book if patron and book exist', async () => { 29 | // given 30 | const checkingOut = new CheckOutBookHandler(willFindBook, patronRepository); 31 | // and 32 | const patronId = persistedRegularPatronWithHold( 33 | patronRepository, 34 | bookOnHold 35 | ); 36 | // when 37 | const result = await checkingOut.execute( 38 | new CheckOutBookCommand(patronId, bookOnHold.bookId) 39 | ); 40 | // then 41 | expect(result).toBe(Result.Success); 42 | }); 43 | 44 | it('should reject checking out book if one of the domain rules is broken (but should not fail!)', async () => { 45 | // given 46 | const checkingOut = new CheckOutBookHandler(willFindBook, patronRepository); 47 | // and 48 | const patronId = persistedRegularPatronWithoutHold(patronRepository); 49 | // when 50 | const result = await checkingOut.execute( 51 | new CheckOutBookCommand(patronId, bookOnHold.bookId) 52 | ); 53 | // then 54 | expect(result).toBe(Result.Rejection); 55 | }); 56 | 57 | it('should fail if patron does not exists', async () => { 58 | // given 59 | const checkingOut = new CheckOutBookHandler(willFindBook, patronRepository); 60 | // and 61 | const patron = unknownPatron(patronRepository); 62 | // when 63 | const result = checkingOut.execute( 64 | new CheckOutBookCommand(patron, bookOnHold.bookId) 65 | ); 66 | // then 67 | await expect(result).rejects.toThrow(); 68 | }); 69 | 70 | it('should fail if book does not exists', async () => { 71 | // given 72 | const checkingOut = new CheckOutBookHandler( 73 | willNotFindBook, 74 | patronRepository 75 | ); 76 | // and 77 | const patron = persistedRegularPatron(patronRepository); 78 | // when 79 | const result = checkingOut.execute( 80 | new CheckOutBookCommand(patron, BookId.generate()) 81 | ); 82 | // then 83 | await expect(result).rejects.toThrow(); 84 | }); 85 | }); 86 | 87 | function unknownPatron(repository: jest.Mocked): PatronId { 88 | repository.findById.mockResolvedValueOnce(none); 89 | return PatronId.generate(); 90 | } 91 | 92 | function persistedRegularPatron( 93 | repository: jest.Mocked 94 | ): PatronId { 95 | const patronId = PatronId.generate(); 96 | const patron = PatronFixtures.GivenRegularPatron(patronId); 97 | repository.findById.mockResolvedValueOnce(some(patron)); 98 | return patronId; 99 | } 100 | 101 | function persistedRegularPatronWithHold( 102 | repository: jest.Mocked, 103 | bookOnHold: BookOnHold 104 | ): PatronId { 105 | const patronId = PatronId.generate(); 106 | const patron = PatronFixtures.regularPatronWithHold(bookOnHold, patronId); 107 | repository.findById.mockResolvedValueOnce(some(patron)); 108 | return patronId; 109 | } 110 | 111 | function persistedRegularPatronWithoutHold( 112 | repository: jest.Mocked 113 | ): PatronId { 114 | const patronId = PatronId.generate(); 115 | const patron = PatronFixtures.regularPatronWithHolds(0); 116 | repository.findById.mockResolvedValueOnce(some(patron)); 117 | return patronId; 118 | } 119 | -------------------------------------------------------------------------------- /libs/lending/domain/src/lib/patron.ts: -------------------------------------------------------------------------------- 1 | import { Either, isLeft, left, right } from 'fp-ts/lib/Either'; 2 | import { getLeft, isNone, none, Option } from 'fp-ts/lib/Option'; 3 | import { AvailableBook } from './book/available-book'; 4 | import { BookOnHold } from './book/book-on-hold'; 5 | import { BookCheckOutFailed } from './events/book-check-out-failed'; 6 | import { BookCheckedOut } from './events/book-checked-out'; 7 | import { BookHoldCanceled } from './events/book-hold-canceled'; 8 | import { BookHoldCancelingFailed } from './events/book-hold-canceling-failed'; 9 | import { BookHoldFailed } from './events/book-hold-failed'; 10 | import { BookPlacedOnHold } from './events/book-placed-on-hold'; 11 | import { BookPlacedOnHoldEvents } from './events/book-placed-on-hold-events'; 12 | import { MaximumNumberOhHoldsReached } from './events/maximum-number-on-holds-reached'; 13 | import { 14 | PlacingOnHoldPolicy, 15 | Rejection, 16 | } from './policies/placing-on-hold-policy'; 17 | import { HoldDuration } from './value-objects/hold-duration'; 18 | import { PatronHolds } from './value-objects/patron-holds'; 19 | import { PatronInformation } from './value-objects/patron-information'; 20 | 21 | export class Patron { 22 | constructor( 23 | private readonly patronHolds: PatronHolds, 24 | private readonly placingOnHoldPolicies: Set, 25 | private readonly patronInformation: PatronInformation 26 | ) {} 27 | 28 | cancelHold( 29 | book: BookOnHold 30 | ): Either { 31 | if (this.patronHolds.includes(book)) { 32 | return right( 33 | new BookHoldCanceled( 34 | this.patronInformation.patronId, 35 | book.bookId, 36 | book.libraryBranchId 37 | ) 38 | ); 39 | } 40 | return left(new BookHoldCancelingFailed(this.patronInformation.patronId)); 41 | } 42 | 43 | checkoutBook(book: BookOnHold): Either { 44 | if (this.patronHolds.includes(book)) { 45 | return right(new BookCheckedOut(this.patronInformation.patronId)); 46 | } 47 | 48 | return left( 49 | BookCheckOutFailed.bookCheckOutFailedBecause( 50 | Rejection.withReason('book is not on hold by patron'), 51 | this.patronInformation.patronId 52 | ) 53 | ); 54 | } 55 | 56 | isRegular(): boolean { 57 | return this.patronInformation.isRegular(); 58 | } 59 | 60 | placeOnCloseEndedHold( 61 | book: AvailableBook, 62 | duration: HoldDuration 63 | ): Either { 64 | return this.placeOnHold(book, duration); 65 | } 66 | 67 | placeOnOpenEndedHold( 68 | book: AvailableBook 69 | ): Either { 70 | return this.placeOnCloseEndedHold(book, HoldDuration.openEnded()); 71 | } 72 | 73 | numberOfHolds(): number { 74 | return this.patronHolds.numberOfHolds; 75 | } 76 | 77 | hasOnHold(book: BookOnHold): boolean { 78 | return this.patronHolds.includes(book); 79 | } 80 | 81 | private patronCanHold( 82 | book: AvailableBook, 83 | duration: HoldDuration 84 | ): Option { 85 | const rejection = [...this.placingOnHoldPolicies] 86 | .map((policy) => policy(book, this, duration)) 87 | .find(isLeft); 88 | return rejection ? getLeft(rejection) : none; 89 | } 90 | 91 | placeOnHold( 92 | book: AvailableBook, 93 | duration: HoldDuration 94 | ): Either { 95 | const rejection = this.patronCanHold(book, duration); 96 | if (isNone(rejection)) { 97 | if (this.patronHolds.maximumHoldsAfterHoldingNextBook()) { 98 | return right( 99 | BookPlacedOnHoldEvents.events( 100 | this.patronInformation.patronId, 101 | new BookPlacedOnHold( 102 | this.patronInformation.patronId, 103 | book.bookId, 104 | book.libraryBranchId, 105 | duration.to 106 | ), 107 | new MaximumNumberOhHoldsReached() 108 | ) 109 | ); 110 | } 111 | return right( 112 | BookPlacedOnHoldEvents.event( 113 | this.patronInformation.patronId, 114 | new BookPlacedOnHold( 115 | this.patronInformation.patronId, 116 | book.bookId, 117 | book.libraryBranchId, 118 | duration.to 119 | ) 120 | ) 121 | ); 122 | } 123 | return left( 124 | BookHoldFailed.bookHoldFailedNow( 125 | rejection.value, 126 | this.patronInformation.patronId 127 | ) 128 | ); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /docs/big-picture.md: -------------------------------------------------------------------------------- 1 | # Big Picture EventStorming 2 | 3 | We started the domain exploration with sticky notes and a pen. What turned out to be the first discovery, 4 | was the **close-ended book holding** process: 5 | 6 | ![Close ended book holding](images/es/bigpicture/close-ended-holding-process.png) 7 | 8 | Let's briefly walk through it: 9 | - A **Regular Patron** can **place a book on a close-ended hold** 10 | - A **Regular Patron** might **reach a maximum holds number** after a **hold is placed** 11 | - A **Regular Patron** can either **cancel the hold** or **check out the book** 12 | - While the book is **checked out** the **hold is completed** and so the **returning process** starts 13 | - Whenever a new day starts, we check the **daily sheet** if a hold is not hanging for too long. If so, 14 | the **book hold is expired** 15 | 16 | On the high level, the process looks clear now, but it does not work the way, that all of us 17 | interpreted each word written no the sticky note similarly. That's why we established some definitions: 18 | 19 | ![Definitions](images/es/bigpicture/definitions-1.png) 20 | 21 | Similar discoveries were made around **open-ened book holding** process: 22 | 23 | ![Open ended book holding](images/es/bigpicture/open-ended-holding-process.png) 24 | 25 | - A **Researcher Patron** can **place a book on an open-ended hold** 26 | - A **Researcher Patron** can either **cancel the hold** or **checkout a book** 27 | - While the book is **checkedout** the **hold is completed** and so the **returning process** starts 28 | - Within the **open-ended holding** a **hold** cannot **expire** (mind the lack of **hold expired** event) 29 | 30 | All right. These two processes are very similar. The part that they have in common, and we know nothing about 31 | it yet is called the **book returning process**: 32 | 33 | ![Book returning process](images/es/bigpicture/the-book-returning-process.png) 34 | 35 | Here's what you see there described with words: 36 | - **Any Patron** can **return a book** 37 | - If the **checkout is overdue**, it is being unregistered as soon as the **book is returned** 38 | - In the moment of **returning a book** we start the process of **Fees application** 39 | - From the moment of **book checkout**, a patron might not return the book on time. Whenever a **new day starts** 40 | we check the **daily sheet** find and **register overdue checkouts** 41 | 42 | Wait, but what is this **checkout**? 43 | 44 | ![Definitions](images/es/bigpicture/definitions-2.png) 45 | 46 | _- OK, now tell me what is this fee application process_ 47 | _- Nope, it is not relevant by now, will get back to it later_ 48 | _- But wait, why? Shouldn't you get the full picture from the storming?_ 49 | _- Yes, but remember, the time has its cost. You always need to focus on the most relevant (at this moment) business part. 50 | I promise to get back to this at the next workshop._ 51 | _- Fair enough!_ 52 | 53 | Fundamental question that raises now is _where do these books come from?_ Looking again at the domain description, 54 | we have a notion of a **catalogue**. We modelled it accordingly: 55 | 56 | ![Catalogue](images/es/bigpicture/book-catalogue.png) 57 | 58 | Here's what happens: 59 | - A **library employee** can add a book into a catalogue 60 | - A specific **book instance** can be **added** as well, thanks to which it can be made **available** under some not 61 | defined yet policy 62 | - Both **Book removed from catalogue** and **Book instance removed from catalogue** are marked with **hot spots**, 63 | as they became problematic. We left answering those problems for the future. 64 | 65 | There is one interesting thing we can spot in this simple **catalogue** flow. **A book** is not the same **book** that 66 | we had in previous processes. To make things clear, let's have a look at new definitions: 67 | 68 | ![Catalogue definitions](images/es/bigpicture/book-catalogue-definitions.png) 69 | 70 | Spotting such differences helps us in drawing linguistic boundaries, that are one of the heuristics for defining 71 | **bounded contexts**. From this moment on, we can assume that we have at least two **bounded contexts**: 72 | * **lending** - context containing all business processes logically connected with book lending, including holding, 73 | checkout, and return 74 | * **catalogue** - contexts for cataloguing books and their instances 75 | 76 | __More information on bounded contexts' defining will be added soon__ 77 | 78 | This is more or less where the first iteration of _Big Picture EventStorming_ finished. After this phase 79 | we had a good understanding of how library processes work on high level, and, what is an invaluable outcome, 80 | we got the **ubiquitous language** including well described definitions, and initial **bounded contexts**. -------------------------------------------------------------------------------- /libs/lending/application/src/lib/place-on-hold/place-on-hold.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { BookId, PatronId } from '@library/lending/domain'; 2 | import { none, some } from 'fp-ts/Option'; 3 | // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries 4 | import { BookFixtures } from '../../../../domain/tests/book.fixtures'; 5 | // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries 6 | import { PatronFixtures } from '../../../../domain/tests/patron.fixtures'; 7 | import { PatronRepository } from '../ports/patron.repository'; 8 | import { FindAvailableBook } from './find-available-book'; 9 | import { PlaceOnHoldCommand } from './place-on-hold.command'; 10 | import { PlaceOnHoldHandler } from './place-on-hold.handler'; 11 | import { createSpyObj } from 'jest-createspyobj'; 12 | import { Result } from '@library/shared/domain'; 13 | 14 | describe('PlaceOnHoldHandler', () => { 15 | const willFindBook: FindAvailableBook = { 16 | findAvailableBookById: () => 17 | Promise.resolve(some(BookFixtures.circulatingBook())), 18 | }; 19 | const willNotFindBook: FindAvailableBook = { 20 | findAvailableBookById: () => Promise.resolve(none), 21 | }; 22 | const repository = createSpyObj(PatronRepository, ['findById', 'publish']); 23 | 24 | it('should successfully place on hold book if patron and book exist', async () => { 25 | // given 26 | const holding = new PlaceOnHoldHandler(willFindBook, repository); 27 | // and 28 | const patron = persistedRegularPatron(repository); 29 | // when 30 | const result = await holding.execute(for3days(patron)); 31 | // then 32 | expect(result).toBe(Result.Success); 33 | }); 34 | 35 | it('should reject placing on hold book if one of the domain rules is broken (but should not fail!)', async () => { 36 | // given 37 | const holding = new PlaceOnHoldHandler(willFindBook, repository); 38 | // and 39 | const patron = persistedRegularPatronWithManyHolds(repository); 40 | // when 41 | const result = await holding.execute(for3days(patron)); 42 | // then 43 | expect(result).toBe(Result.Rejection); 44 | }); 45 | 46 | it('should fail if patron does not exists', async () => { 47 | // given 48 | const holding = new PlaceOnHoldHandler(willFindBook, repository); 49 | // and 50 | const patron = unknownPatron(repository); 51 | // when 52 | const result = holding.execute(for3days(patron)); 53 | // then 54 | await expect(result).rejects.toThrow(); 55 | }); 56 | 57 | it('should fail if book does not exists', async () => { 58 | // given 59 | const holding = new PlaceOnHoldHandler(willNotFindBook, repository); 60 | // and 61 | const patron = persistedRegularPatron(repository); 62 | // when 63 | const result = holding.execute(for3days(patron)); 64 | // then 65 | await expect(result).rejects.toThrow(); 66 | }); 67 | 68 | it('should fail if saving patron fails', async () => { 69 | // given 70 | const holding = new PlaceOnHoldHandler(willNotFindBook, repository); 71 | // and 72 | const patron = persistedRegularPatronThatFailsOnSaving(repository); 73 | // when 74 | const result = holding.execute(for3days(patron)); 75 | // then 76 | await expect(result).rejects.toThrow(); 77 | }); 78 | }); 79 | 80 | function persistedRegularPatron( 81 | repository: jest.Mocked 82 | ): PatronId { 83 | const patronId = PatronId.generate(); 84 | const patron = PatronFixtures.GivenRegularPatron(patronId); 85 | repository.findById.mockResolvedValueOnce(some(patron)); 86 | return patronId; 87 | } 88 | 89 | function persistedRegularPatronWithManyHolds( 90 | repository: jest.Mocked 91 | ): PatronId { 92 | const patronId = PatronId.generate(); 93 | const patron = PatronFixtures.regularPatronWithHolds(10); 94 | repository.publish.mockResolvedValueOnce(patron); 95 | repository.findById.mockResolvedValueOnce(some(patron)); 96 | return patronId; 97 | } 98 | 99 | function for3days(patron: PatronId): PlaceOnHoldCommand { 100 | return PlaceOnHoldCommand.closeEnded(patron, BookId.generate(), 4); 101 | } 102 | 103 | function unknownPatron(repository: jest.Mocked): PatronId { 104 | repository.findById.mockResolvedValueOnce(none); 105 | return PatronId.generate(); 106 | } 107 | 108 | function persistedRegularPatronThatFailsOnSaving( 109 | repository: jest.Mocked 110 | ): PatronId { 111 | const patronId = PatronId.generate(); 112 | const patron = PatronFixtures.GivenRegularPatron(patronId); 113 | repository.findById.mockResolvedValueOnce(some(patron)); 114 | repository.publish.mockRejectedValueOnce(new Error('Mocked to fail')); 115 | return patronId; 116 | } 117 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/src/lib/typeorm/repositories/book.repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BookRepository, 3 | FindAvailableBook, 4 | FindBookOnHold, 5 | } from '@library/lending/application'; 6 | import { 7 | AvailableBook, 8 | Book, 9 | BookId, 10 | BookOnHold, 11 | PatronId, 12 | } from '@library/lending/domain'; 13 | import { AggregateRootIsStale } from '@library/shared/domain'; 14 | import { Injectable } from '@nestjs/common'; 15 | import { InjectRepository } from '@nestjs/typeorm'; 16 | import { option } from 'fp-ts'; 17 | import { pipe } from 'fp-ts/lib/function'; 18 | import { match, none, Option, some } from 'fp-ts/Option'; 19 | import { Repository } from 'typeorm'; 20 | import { BookEntity, BookState } from '../entities/book.entity'; 21 | 22 | @Injectable() 23 | export class BookRepo 24 | implements FindAvailableBook, FindBookOnHold, BookRepository 25 | { 26 | constructor( 27 | @InjectRepository(BookEntity) 28 | private readonly bookRepo: Repository 29 | ) {} 30 | 31 | async findById(id: BookId): Promise> { 32 | return pipe( 33 | option.fromNullable(await this.bookRepo.findOne(id.value)), 34 | option.map((book) => book.toDomainModel()) 35 | ); 36 | } 37 | 38 | async findBookOnHold( 39 | bookId: BookId, 40 | patronId: PatronId 41 | ): Promise> { 42 | const maybeBook = await this.findById(bookId); 43 | return pipe( 44 | maybeBook, 45 | match( 46 | () => none, 47 | (book) => { 48 | if (book instanceof BookOnHold) { 49 | return some(book); 50 | } 51 | return none; 52 | } 53 | ) 54 | ); 55 | } 56 | 57 | async findAvailableBookById(id: BookId): Promise> { 58 | const maybeBook = await this.findById(id); 59 | return pipe( 60 | maybeBook, 61 | match( 62 | () => none, 63 | (book) => { 64 | if (book instanceof AvailableBook) { 65 | return some(book); 66 | } 67 | return none; 68 | } 69 | ) 70 | ); 71 | } 72 | 73 | async save(book: Book): Promise { 74 | const maybeBook = await this.findById(book.bookId); 75 | const result = await pipe( 76 | maybeBook, 77 | option.match( 78 | () => this.inserNew(book), 79 | () => this.updateOptimistically(book) 80 | ) 81 | ); 82 | 83 | if (result === 0) { 84 | throw new AggregateRootIsStale( 85 | 'Someone has updated book in the meantime, book: ' + book 86 | ); 87 | } 88 | 89 | return result; 90 | } 91 | 92 | inserNew(book: Book): Promise { 93 | switch (book.constructor) { 94 | case AvailableBook: 95 | return this.insertAvailableBook(book as AvailableBook); 96 | case BookOnHold: 97 | return this.insertBookOnHold(book as BookOnHold); 98 | default: 99 | throw new Error(`Can't insert book of the unknown type`); 100 | } 101 | } 102 | 103 | async insertAvailableBook(book: AvailableBook): Promise { 104 | await this.bookRepo.insert( 105 | BookEntity.restore({ 106 | bookId: book.bookId.value, 107 | state: BookState.Available, 108 | availableAtBranch: book.libraryBranchId.value, 109 | onHoldAtBranch: null, 110 | onHoldByPatron: null, 111 | version: 0, 112 | }) 113 | ); 114 | } 115 | 116 | async insertBookOnHold(book: BookOnHold): Promise { 117 | await this.bookRepo.insert( 118 | BookEntity.restore({ 119 | bookId: book.bookId.value, 120 | state: BookState.OnHold, 121 | availableAtBranch: null, 122 | onHoldAtBranch: book.libraryBranchId.value, 123 | onHoldByPatron: book.patronId.value, 124 | version: 0, 125 | }) 126 | ); 127 | } 128 | 129 | updateOptimistically(book: Book): any { 130 | switch (book.constructor) { 131 | case AvailableBook: 132 | return this.updateAvailableBook(book as AvailableBook); 133 | case BookOnHold: 134 | return this.updateBookOnHold(book as BookOnHold); 135 | default: 136 | throw new Error(`Can't insert book of the unknown type`); 137 | } 138 | } 139 | 140 | async updateAvailableBook(book: AvailableBook): Promise { 141 | await this.bookRepo.update( 142 | { bookId: book.bookId.value, version: book.version.value }, 143 | BookEntity.restore({ 144 | bookId: book.bookId.value, 145 | state: BookState.Available, 146 | availableAtBranch: book.libraryBranchId.value, 147 | onHoldAtBranch: null, 148 | onHoldByPatron: null, 149 | version: book.version.value + 1, 150 | }) 151 | ); 152 | } 153 | 154 | async updateBookOnHold(book: BookOnHold): Promise { 155 | await this.bookRepo.update( 156 | { bookId: book.bookId.value, version: book.version.value }, 157 | BookEntity.restore({ 158 | bookId: book.bookId.value, 159 | state: BookState.OnHold, 160 | availableAtBranch: null, 161 | onHoldAtBranch: book.libraryBranchId.value, 162 | onHoldByPatron: book.patronId.value, 163 | version: book.version.value + 1, 164 | }) 165 | ); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /libs/lending/infrastructure/src/lib/typeorm/repositories/patron.repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { PatronRepository } from '@library/lending/application'; 2 | import { 3 | BookHoldCanceled, 4 | BookId, 5 | BookPlacedOnHold, 6 | DateVO, 7 | LibraryBranchId, 8 | PatronId, 9 | PatronType, 10 | } from '@library/lending/domain'; 11 | import { ConfigModule } from '@nestjs/config'; 12 | import { Test, TestingModule } from '@nestjs/testing'; 13 | import { getRepositoryToken, TypeOrmModule } from '@nestjs/typeorm'; 14 | import { Repository } from 'typeorm'; 15 | import { BookEntity, BookState } from '../entities/book.entity'; 16 | import { HoldEntity } from '../entities/hold.entity'; 17 | import { PatronEntity } from '../entities/patron.entity'; 18 | import { LendingTypeOrmModule } from '../lending-typeorm.module'; 19 | import { PatronRepo } from './patron.repository'; 20 | 21 | describe('PatronRepository', () => { 22 | let patronRepo: PatronRepo; 23 | let moduleRef: TestingModule; 24 | 25 | beforeAll(async () => { 26 | moduleRef = await Test.createTestingModule({ 27 | imports: [ 28 | ConfigModule.forRoot(), 29 | TypeOrmModule.forRoot({ 30 | type: 'postgres', 31 | host: process.env.DB_HOST, 32 | port: 5432, 33 | username: process.env.DB_USER, 34 | password: process.env.DB_PASSWORD, 35 | database: process.env.DB_NAME, 36 | autoLoadEntities: true, 37 | synchronize: true, 38 | }), 39 | LendingTypeOrmModule, 40 | ], 41 | }).compile(); 42 | 43 | patronRepo = moduleRef.get(PatronRepository); 44 | }); 45 | 46 | describe('Given patron', () => { 47 | let patronTypeOrmRepo: Repository; 48 | const patronId = '55760e4e-9aa9-4754-ae26-159df2fd03dd'; 49 | beforeAll(async () => { 50 | patronTypeOrmRepo = moduleRef.get(getRepositoryToken(PatronEntity)); 51 | await patronTypeOrmRepo.insert( 52 | PatronEntity.restore({ 53 | id: patronId, 54 | booksOnHold: [], 55 | patronType: PatronType.Regular, 56 | }) 57 | ); 58 | }); 59 | 60 | afterAll(async () => { 61 | await patronTypeOrmRepo.delete(patronId); 62 | }); 63 | 64 | describe('And available book', () => { 65 | let bookRepo: Repository; 66 | const bookId = '55760e4e-9aa9-4754-ae26-159df2fd03dd'; 67 | const libraryBranchId = '55760e4e-9aa9-4754-ae26-159df2fd03dd'; 68 | 69 | beforeAll(async () => { 70 | bookRepo = moduleRef.get(getRepositoryToken(BookEntity)); 71 | await bookRepo.insert( 72 | BookEntity.restore({ 73 | bookId, 74 | availableAtBranch: libraryBranchId, 75 | state: BookState.Available, 76 | onHoldAtBranch: null, 77 | onHoldByPatron: null, 78 | version: 0, 79 | }) 80 | ); 81 | }); 82 | 83 | afterAll(async () => { 84 | await bookRepo.delete(bookId); 85 | }); 86 | 87 | describe('publish', () => { 88 | describe('BookPlacedOnHold', () => { 89 | let holdsRepo: Repository; 90 | 91 | beforeAll(async () => { 92 | holdsRepo = moduleRef.get(getRepositoryToken(HoldEntity)); 93 | await patronRepo.publish( 94 | new BookPlacedOnHold( 95 | new PatronId(patronId), 96 | new BookId(bookId), 97 | new LibraryBranchId(libraryBranchId), 98 | DateVO.now() 99 | ) 100 | ); 101 | }); 102 | 103 | afterAll(async () => { 104 | await holdsRepo.delete({ patronId }); 105 | }); 106 | 107 | it('should add record to holds table', async () => { 108 | expect(await holdsRepo.count({ patronId })).toBe(1); 109 | }); 110 | }); 111 | }); 112 | }); 113 | }); 114 | 115 | describe('Given patron', () => { 116 | describe('And his book on hold', () => { 117 | let patronTypeOrmRepo: Repository; 118 | const patronId = '55760e4e-9aa9-4754-ae26-159df2fd03dd'; 119 | 120 | beforeAll(async () => { 121 | bookRepo = moduleRef.get(getRepositoryToken(BookEntity)); 122 | await bookRepo.insert( 123 | BookEntity.restore({ 124 | bookId, 125 | availableAtBranch: null, 126 | onHoldAtBranch: libraryBranchId, 127 | state: BookState.OnHold, 128 | onHoldByPatron: patronId, 129 | version: 1, 130 | }) 131 | ); 132 | patronTypeOrmRepo = moduleRef.get(getRepositoryToken(PatronEntity)); 133 | await patronTypeOrmRepo.save( 134 | patronTypeOrmRepo.create( 135 | PatronEntity.restore({ 136 | id: patronId, 137 | booksOnHold: [ 138 | HoldEntity.create({ bookId, libraryBranchId, patronId }), 139 | ], 140 | patronType: PatronType.Regular, 141 | }) 142 | ) 143 | ); 144 | }); 145 | 146 | afterAll(async () => { 147 | await patronTypeOrmRepo.delete(patronId); 148 | await bookRepo.delete(bookId); 149 | }); 150 | 151 | let bookRepo: Repository; 152 | const bookId = '55760e4e-9aa9-4754-ae26-159df2fd03dd'; 153 | const libraryBranchId = '55760e4e-9aa9-4754-ae26-159df2fd03dd'; 154 | 155 | describe('publish', () => { 156 | describe('BookHoldCanceled', () => { 157 | let holdsRepo: Repository; 158 | 159 | beforeAll(async () => { 160 | holdsRepo = moduleRef.get(getRepositoryToken(HoldEntity)); 161 | await patronRepo.publish( 162 | new BookHoldCanceled( 163 | new PatronId(patronId), 164 | new BookId(bookId), 165 | new LibraryBranchId(libraryBranchId) 166 | ) 167 | ); 168 | }); 169 | 170 | it('should remove record from holds table', async () => { 171 | expect(await holdsRepo.count({ where: { patronId } })).toBe(0); 172 | }); 173 | }); 174 | }); 175 | }); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | 3 | - [Table of contents](#table-of-contents) 4 | 5 | - [About](#about) 6 | - [Domain description](#domain-description) 7 | - [General assumptions](#general-assumptions) 8 | - [Process discovery](#process-discovery) 9 | - [Project structure and architecture](#project-structure-and-architecture) 10 | - [Architecture-code gap](#architecture-code-gap) 11 | - [NestJS](#nestjs) 12 | - [How to contribute](#how-to-contribute) 13 | - [References](#references) 14 | 15 | - [How to contribute](#how-to-contribute) 16 | - [References](#references) 17 | 18 | ## About 19 | 20 | This project is an implementation of the well-known [ddd-by-examples/library](https://github.com/ddd-by-examples/library), but this time using TypeScript and Node.js. 21 | 22 | This is a project of a library, driven by real [business requirements](#domain-description). 23 | We use techniques strongly connected with Domain Driven Design, Behavior-Driven Development, 24 | Event Storming, User Story Mapping. 25 | 26 | **The project is currently under development. Some solutions are temporary and may change.** 27 | 28 | ## Domain description 29 | 30 | A public library allows patrons to place books on hold at its various library branches. 31 | Available books can be placed on hold only by one patron at any given point in time. 32 | Books are either circulating or restricted, and can have retrieval or usage fees. 33 | A restricted book can only be held by a researcher patron. A regular patron is limited 34 | to five holds at any given moment, while a researcher patron is allowed an unlimited number 35 | of holds. An open-ended book hold is active until the patron checks out the book, at which time it 36 | is completed. A closed-ended book hold that is not completed within a fixed number of 37 | days after it was requested will expire. This check is done at the beginning of a day by 38 | taking a look at daily sheet with expiring holds. Only a researcher patron can request 39 | an open-ended hold duration. Any patron with more than two overdue checkouts at a library 40 | branch will get a rejection if trying a hold at that same library branch. A book can be 41 | checked out for up to 60 days. Check for overdue checkouts is done by taking a look at 42 | daily sheet with overdue checkouts. Patron interacts with his/her current holds, checkouts, etc. 43 | by taking a look at patron profile. Patron profile looks like a daily sheet, but the 44 | information there is limited to one patron and is not necessarily daily. Currently a 45 | patron can see current holds (not canceled nor expired) and current checkouts (including overdue). 46 | Also, he/she is able to hold a book and cancel a hold. 47 | 48 | How actually a patron knows which books are there to lend? Library has its catalogue of 49 | books where books are added together with their specific instances. A specific book 50 | instance of a book can be added only if there is book with matching ISBN already in 51 | the catalogue. Book must have non-empty title and price. At the time of adding an instance 52 | we decide whether it will be Circulating or Restricted. This enables 53 | us to have book with same ISBN as circulated and restricted at the same time (for instance, 54 | there is a book signed by the author that we want to keep as Restricted) 55 | 56 | ## General assumptions 57 | 58 | ### Process discovery 59 | 60 | The first thing we started with was domain exploration with the help of Big Picture EventStorming. 61 | The description you found in the previous chapter, landed on our virtual wall: 62 | ![Event Storming Domain description](docs/images/eventstorming-domain-desc.png) 63 | The EventStorming session led us to numerous discoveries, modeled with the sticky notes: 64 | ![Event Storming Big Picture](docs/images/eventstorming-big-picture.jpg) 65 | During the session we discovered following definitions: 66 | ![Event Storming Definitions](docs/images/eventstorming-definitions.png) 67 | 68 | This made us think of real life scenarios that might happen. We discovered them described with the help of 69 | the **Example mapping**: 70 | ![Example mapping](docs/images/example-mapping.png) 71 | 72 | This in turn became the base for our _Design Level_ sessions, where we analyzed each example: 73 | ![Example mapping](docs/images/eventstorming-design-level.jpg) 74 | 75 | Please follow the links below to get more details on each of the mentioned steps: 76 | 77 | - [Big Picture EventStorming](./docs/big-picture.md) 78 | - [Example Mapping](docs/example-mapping.md) 79 | - [Design Level EventStorming](docs/design-level.md) 80 | 81 | ### Project structure and architecture 82 | 83 | At the very beginning, not to overcomplicate the project, we decided to assign each bounded context 84 | to a separate package, which means that the system is a modular monolith. There are no obstacles, though, 85 | to put contexts into maven modules or finally into microservices. 86 | 87 | Bounded contexts should (amongst others) introduce autonomy in the sense of architecture. Thus, each module 88 | encapsulating the context has its own local architecture aligned to problem complexity. 89 | In the case of a context, where we identified true business logic (**lending**) we introduced a domain model 90 | that is a simplified (for the purpose of the project) abstraction of the reality and utilized 91 | hexagonal architecture. In the case of a context, that during Event Storming turned out to lack any complex 92 | domain logic, we applied CRUD-like local architecture. 93 | 94 | ### Architecture-code gap 95 | 96 | We put a lot of attention to keep the consistency between the overall architecture (including diagrams) 97 | and the code structure. Having identified bounded contexts we could organize them as a set of libraries (one of the reasons why Nx is used). Thanks to this we gain the famous microservices' autonomy, while having a monolithic 98 | application (modular monolith). Each package has well defined public API, encapsulating all implementation details by using 99 | _nx-enforce-module-boundaries_. 100 | 101 | Just by looking at the package structure: 102 | 103 | ``` 104 | └── libs 105 | ├── catalogue 106 | └── lending 107 |    ├── application 108 |    ├── infrastructure 109 |    ├── ui-rest 110 |    └── domain/ 111 | ├── /book 112 | ├── /dailysheet 113 | ├── /librarybranch 114 | ├── /patron 115 | └── /patronprofile 116 | ``` 117 | 118 | you can see that the architecture is screaming that it has two bounded contexts: **catalogue** 119 | and **lending**. Moreover, the **lending context** is built around five business objects: **book**, 120 | **dailysheet**, **librarybranch**, **patron**, and **patronprofile**, while **catalogue** has no sublibraries, 121 | which suggests that it might be a CRUD with no complex logic inside. Please find the architecture diagram 122 | below. 123 | You may ask why, unlike a Java project, all business objects don't have their own hexagonal libraries. Indeed all business objects have now only their own catalog in each tier. 124 | We changed that here because in Java these libraries made extensive use of each other, while in Node.js it will produce a circular dependency. So, when two libraries strongly depend on each other, they have to be merged together. 125 | 126 | ![Component diagram](docs/c4/component-diagram.png) 127 | 128 | Yet another advantage of this approach comparing to packaging by layer for example is that in order to 129 | deliver a functionality you would usually need to do it in one package only, which is the aforementioned 130 | autonomy. This autonomy, then, could be transferred to the level of application as soon as we split our 131 | _context-packages_ into separate microservices. Following this considerations, autonomy can be given away 132 | to a product team that can take care of the whole business area end-to-end. 133 | 134 | #### NestJS 135 | 136 | NestJS is taking a big part of the market. Currently, the most popular framework is still Express, but for complex business applications, NestJS will fit better, thanks to its advanced Dependency Injection system, TypeScript as the main language, and many out-of-the-box solutions that make the development more organized and standardized from the very beginning. 137 | 138 | In oposite to the goal from the [ddd-by-examples/library#spring](https://github.com/ddd-by-examples/library#spring), we will not categorically avoid dependence on our framework. As Eric Evans said in his book 139 | 140 | > The best architectural frameworks solve complex technical 141 | > problems while allowing the domain developer to concentrate on expressing a model. But frameworks can easily get in the way, either by making too many assumptions that constrain domain 142 | > design choices or by making the implementation so heavyweight that development slows down. 143 | 144 | Following that sentence, we will try still using the framework in a few places to speed up the development without strongly affecting the structure of our model. 145 | 146 | ## How to contribute 147 | 148 | The project is still under construction, so if you like it enough to collaborate, just let us 149 | know or simply create a Pull Request. 150 | 151 | ## References 152 | 153 | 1. [Introducing EventStorming](https://leanpub.com/introducing_eventstorming) by Alberto Brandolini 154 | 2. [Domain Modelling Made Functional](https://pragprog.com/book/swdddf/domain-modeling-made-functional) by Scott Wlaschin 155 | 3. [Software Architecture for Developers](https://softwarearchitecturefordevelopers.com) by Simon Brown 156 | 4. [Clean Architecture](https://www.amazon.com/Clean-Architecture-Craftsmans-Software-Structure/dp/0134494164) by Robert C. Martin 157 | 5. [Domain-Driven Design: Tackling Complexity in the Heart of Software](https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215) by Eric Evans 158 | -------------------------------------------------------------------------------- /docs/design-level.md: -------------------------------------------------------------------------------- 1 | # Design Level EventStorming 2 | 3 | As soon as we got our examples written down, we could start digging deep into each of them, identifying key interactions 4 | with the system, spotting business rules and constantly refining the model. In the following sections you will 5 | find mentioned examples modelled with Design Level EventStorming. 6 | 7 | ## Holding 8 | ### Regular patron 9 | 10 | The first example is _the one when regular patron tries to place his 6th hold_: 11 | 12 | ![Holding example 1](images/dl/holding/example-1.png) 13 | 14 | What you can see here is that we are assuming, that a particular patron has already placed 5 books on hold. 15 | Next, in order to place one more, a patron needs to interact with _the system_ somehow, so this is the reason 16 | for placing a blue sticky note representing a command called **place on hold**. In order to make such decision, 17 | a patron needs to have some view of the book that can be potentially placed on hold (green sticky note). 18 | Because the regular patron cannot place more than 5 books on hold, we could identify a rule (rectangular yellow sticky note), 19 | that describes conditions that needs to be fulfilled for the **Book hold failed** event to occur. 20 | 21 | Fair enough, let's go further. 22 | 23 | When a **patron** tries to place on hold a book that is currently not available it should not be possible, thus resulting 24 | in **book hold failed** event, as it is depicted below: 25 | 26 | ![Holding example 2](images/dl/holding/example-2.png) 27 | 28 | Taking a look at the domain description again, we find out that each patron can have no more than 2 **overdue checkouts**. 29 | In such situation, every attempt to **place a book on hold** should fail: 30 | 31 | ![Holding example 3](images/dl/holding/example-3.png) 32 | 33 | If we are talking about **regular patrons**, what is special about them is that they are not allowed to hold a 34 | **restricted book**: 35 | 36 | ![Holding example 4](images/dl/holding/example-4.png) 37 | 38 | Second thing that is not allowed for a **regular patron** is **open-ended** hold: 39 | ![Holding example 12](images/dl/holding/example-12.png) 40 | 41 | All right, enough with failures, let patrons lend some books, eventually: 42 | 43 | ![Holding example 5](images/dl/holding/example-5.png) 44 | 45 | Having in mind all previous examples, we discovered following conditions that need to be fulfilled for **patron** to 46 | **place a book on hold**: 47 | * Book must be available 48 | * Book must not be **restricted** 49 | * At the moment of placing a hold, a patron cannot have more than 4 holds 50 | * Patron cannot have more than 1 overdue checkout 51 | 52 | And here is the last example, partially covered before: 53 | 54 | ![Holding example 6](images/dl/holding/example-6.png) 55 | 56 | ### Researcher patron 57 | 58 | In the previous part of this paragraph we focused on a *regular patron* only. Let's have a look at *researcher patron* now. 59 | The domain description clearly states that **any** patron with more than 2 **overdue checkouts** will get a rejection 60 | when trying to place book on hold. So we have it modelled: 61 | 62 | ![Holding example 7](images/dl/holding/example-7.png) 63 | 64 | There is also no exception in terms of holding a book that is **not available**: 65 | 66 | ![Holding example 8](images/dl/holding/example-8.png) 67 | 68 | The thing that differentiates **researcher patron** from a **regular** one is that he/she can place on hold a **restricted** 69 | book: 70 | 71 | ![Holding example 9](images/dl/holding/example-9.png) 72 | 73 | Last three examples depict successful holding scenarios: 74 | 75 | ![Holding example 10](images/dl/holding/example-10.png) 76 | ![Holding example 11](images/dl/holding/example-11.png) 77 | ![Holding example 13](images/dl/holding/example-13.png) 78 | 79 | ## Canceling a hold 80 | 81 | Any patron can cancel the hold. The unbreakable condition to be fulfilled is the one that the hold exists. 82 | If it is not the case **book hold cancelling failed** event occurs. What you can spot here is that now the **patron**, 83 | in order to cancel a hold, he/she needs to have a view of current holds (mind the **Holds view** green sticky note). 84 | 85 | ![Canceling hold example 1](images/dl/cancelinghold/example-1.png) 86 | 87 | If the hold is present, then it should be possible to cancel it: 88 | 89 | ![Canceling hold example 2](images/dl/cancelinghold/example-2.png) 90 | 91 | We also need to take care of the scenario when a **patron** tries to **cancel a hold** that was actually 92 | not placed by himself/herself: 93 | 94 | ![Canceling hold example 3](images/dl/cancelinghold/example-3.png) 95 | 96 | It shouldn't be also possible to **cancel a hold** twice: 97 | 98 | ![Canceling hold example 5](images/dl/cancelinghold/example-5.png) 99 | 100 | Getting back to holding-related examples, let's try to join them with hold cancellation. Each **patron** can have no more 101 | than five holds at a particular point in time. Thus, cancelling one of them should be enough for **patron** to **place 102 | on hold** another book: 103 | 104 | ![Canceling hold example 4](images/dl/cancelinghold/example-4.png) 105 | 106 | ## Checkout 107 | 108 | Checking out is actually the essence of library functioning. **Any patron** can checkout a hold, but it is only possible 109 | when the **hold** exists: 110 | 111 | ![Checkout example 1](images/dl/bookcheckouts/example-1.png) 112 | 113 | It is also not allowed to checkout someone else's hold: 114 | 115 | ![Checkout example 2](images/dl/bookcheckouts/example-2.png) 116 | 117 | An example summing things up is depicted below: 118 | 119 | ![Checkout example 3](images/dl/bookcheckouts/example-3.png) 120 | 121 | A real-life scenario could be that a **patron** cancels his/her hold, and tries to check the book out: 122 | 123 | ![Checkout example 4](images/dl/bookcheckouts/example-4.png) 124 | 125 | It might also happen that a **patron** has the hold, whereas the book is missing in a library: 126 | 127 | ![Checkout example 5](images/dl/bookcheckouts/example-5.png) 128 | 129 | ## Expiring a hold 130 | 131 | According to the domain description, any **close-ended hold** is active until it is either checked out by **patron** or 132 | expired. The expiration check is done automatically by the system at the **beginning of the day**. In order to find holds 133 | that qualify to expiration, a system needs to have a read model of such entries. Domain description names it a **Daily sheet** 134 | (please mind the green sticky note) 135 | 136 | ![Expiring hold example 1](images/dl/expiringhold/example-1.png) 137 | 138 | When the book is **placed on hold** and the hold is **cancelled** before its expiration due date, it shouldn't be registered 139 | as expired hold: 140 | 141 | ![Expiring hold example 2](images/dl/expiringhold/example-2.png) 142 | 143 | The expiration check should mark each hold as expired only once: 144 | 145 | ![Expiring hold example 3](images/dl/expiringhold/example-3.png) 146 | 147 | ## Registering overdue checkout 148 | 149 | Each book can be checked out for not longer than 60 days. **Overdue checkouts** are identified on a daily basis by looking 150 | at the **Daily sheet** (please mind the green sticky note): 151 | 152 | ![Overdue checkout example 1](images/dl/overduecheckouts/example-1.png) 153 | 154 | Moreover we do not expect the **returned book** to be ever registered as **overdue checkout**: 155 | 156 | ![Overdue checkout example 2](images/dl/overduecheckouts/example-2.png) 157 | 158 | ## Adding to catalogue 159 | 160 | The last area of analysis is the book **catalogue**. Catalogue is a collection of books and their instances. 161 | A book instance can be added only when there is a book with matching ISBN already registered in the catalogue: 162 | 163 | ![Catalogue example 1](images/dl/addingtocatalogue/example-1.png) 164 | 165 | If this is not the case, adding a book instance into catalogue should end up with failure. 166 | ![Catalogue example 2](images/dl/addingtocatalogue/example-2.png) 167 | 168 | 169 | ## Bounded Context Classification 170 | 171 | Until now, we have already identified two **bounded contexts** - **lending contexts**, and **catalogue contexts**. 172 | Having in mind the domain description, and looking at the amount of discovered business rules, we can clearly see, 173 | that **lending context** is the one that requires a lot of attention. Comparing the business complexities of both 174 | contexts led us to conclusion that using **tactical building blocks** of **Domain Driven Design** and applying 175 | **hexagonal architecture** are a reasonable choice for **lending context** while **catalogue context** is just 176 | a simple **CRUD**, and applying the same local architecture would be over-engineering. 177 | 178 | You may ask yourself now: __how do you know that **catalogue context** is a CRUD?__. Here's a heuristic. 179 | If most of the events, named as verbs in past tense, are triggered by commands, being named with the same verbs 180 | but as imperatives, then it means we are probably just creating, updating, or deleting an object from some database. 181 | Moreover, if there are no specific (or very little) business rules, then it might suggest that the essential complexity 182 | sourced in the business is low enough for CRUD to be well applicable. 183 | 184 | ## Aggregates 185 | 186 | What you could see in the above examples is that we have not specified the **aggregates** that would be responsible for 187 | handling commands and emitting events. 188 | Such approach keeps us away from being steered into a particular solution/language and consequently limited from the very 189 | beginning. Looking at behaviours and responsibilities first lets us understand the problem better, and thus find 190 | a better name of the **aggregate**. In this paragraph you will see how we worked out the final aggregate model. 191 | 192 | The first shot was to use **Book** as an aggregate. We are __placing **a book** on hold__, __cancelling the hold for **a book**__, 193 | __checking **a book** out__ - all this sentences make logical sense, and even suits linguistically: 194 | 195 | ![Aggregate 1](images/aggregates/agg-1.png) 196 | 197 | The first question that raised, was: __What about the invariants? Do they apply to a book?__. Well, not only. 198 | When you take a look again at the rules that we discovered in previous paragraphs, you will see things like: 199 | * is the patron a **regular** one or a **researcher**? 200 | * is patron's maximum number of holds reached? 201 | * is patron's maximum number of patron's overdue checkouts reached? 202 | * is book available? 203 | * is book restricted? 204 | Book availability and its potential restriction (which is actually a property/characteristic) does not seem to be 205 | as critical as those connected with patrons. Secondly, we have more patron-related rules than book-related ones. 206 | 207 | OK, but why don't we just pass the **Patron** object into **Book's** methods like: 208 | ```java 209 | book.placeOnHoldBy(patron); 210 | ``` 211 | We could, but it is the **patron** that knows more invariants, and we do not want to let any other object to protect them. 212 | Here is the alternative, then: 213 | 214 | ![Aggregate 2](images/aggregates/agg-2.png) 215 | 216 | Okay, so now in order to for example __place a hold__ we need to pass a **Book** object into a **Patron**, right? 217 | 218 | ```java 219 | patron.hold(book); 220 | ``` 221 | 222 | Then, if both patron's and book's invariants pass, we would modify patron and book aggregates. But doesn't it sound like 223 | modifying two different aggregates in one transaction? Moreover, there is one more catch. Book's invariants (including its 224 | availability) are just our "best wish". Our book model is just an abstraction of the real world books to lend in a library. 225 | Why? Because in the real world a book that is placed on hold, might be found damaged or lost in the meantime. 226 | Patron's invariants are more likely to be up to date and "driven" by our system. Gauges like number of holds, overdue checkouts 227 | are much easier to be "real ones". This in turn means that it is okay to follow (suggested - after all) eventual consistency 228 | model of inter-aggregate communication. It would make our model more realistic. Classes would be smaller, and easier 229 | to work with and to maintain. 230 | 231 | We have 2 aggregates now. We could revise the decision of *Patron* being the first aggregate to be modified, and the 232 | **Book** being consistent in the future (eventually). We have already concluded that the **Book** is just a nice 233 | projection of the real world plus patron has more invariants to drive the process. Also, these invariants are more 234 | likely to be relevant. It is also probably less harmful, then, to place on hold a book which is actually not available 235 | (and run compensation process) than let patrons place books on hold while having overdue checkouts. 236 | 237 | Now the final model is following: 238 | 239 | ![Aggregate 3](images/aggregates/agg-3.png) 240 | --------------------------------------------------------------------------------