├── test ├── mocha.opts ├── NonNullableRepository-test.ts ├── ValueObject-test.ts ├── Identifier-test.ts ├── Entitiy-test.ts ├── Serializer-test.ts ├── Domain-scenario-test.ts ├── Copyable-test.ts ├── Converter-test.ts └── RepositoryCore-test.ts ├── .travis.yml ├── renovate.json ├── prettier.config.js ├── src ├── TypeUtil.ts ├── Serializer.ts ├── EntityLike.ts ├── repository │ ├── Repository.ts │ ├── NullableRepository.ts │ ├── NonNullableRepository.ts │ ├── RepositoryEventEmitter.ts │ └── RepositoryCore.ts ├── ValueObject.ts ├── index.ts ├── Identifier.ts ├── Entity.ts ├── mixin │ └── Copyable.ts └── Converter.ts ├── tsconfig.json ├── LICENSE ├── package.json ├── .gitignore └── README.md /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ts-node/register 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: "stable" 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@azu:maintenance" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | tabWidth: 4 4 | }; 5 | -------------------------------------------------------------------------------- /src/TypeUtil.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Constructor type 3 | */ 4 | export type Constructor = new (...args: any[]) => T; 5 | -------------------------------------------------------------------------------- /src/Serializer.ts: -------------------------------------------------------------------------------- 1 | // MIT © 2017 azu 2 | export interface Serializer { 3 | /** 4 | * Convert Entity to JSON format 5 | */ 6 | toJSON(entity: Entity): JSON; 7 | 8 | /** 9 | * Convert JSON to Entity 10 | */ 11 | fromJSON(json: JSON): Entity; 12 | } 13 | -------------------------------------------------------------------------------- /src/EntityLike.ts: -------------------------------------------------------------------------------- 1 | import { Identifier } from "./Identifier"; 2 | 3 | export interface EntityLikeProps> { 4 | id: Id; 5 | [index: string]: any; 6 | } 7 | 8 | export interface EntityLike>> { 9 | props: Props; 10 | 11 | equals(object?: EntityLike): boolean; 12 | } 13 | -------------------------------------------------------------------------------- /src/repository/Repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityLike } from "../EntityLike"; 2 | import { MapLike } from "map-like"; 3 | import { RepositoryEventEmitter } from "./RepositoryEventEmitter"; 4 | 5 | export abstract class Repository< 6 | Entity extends EntityLike, 7 | Props extends Entity["props"] = Entity["props"], 8 | Id extends Props["id"] = Props["id"] 9 | > { 10 | abstract get map(): MapLike; 11 | 12 | abstract get events(): RepositoryEventEmitter; 13 | 14 | abstract get(): Entity | undefined; 15 | 16 | abstract getAll(): Entity[]; 17 | 18 | abstract findById(entityId?: Id): Entity | undefined; 19 | 20 | abstract save(entity: Entity): void; 21 | 22 | abstract delete(entity: Entity): void; 23 | 24 | abstract clear(): void; 25 | } 26 | -------------------------------------------------------------------------------- /test/NonNullableRepository-test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { Entity, Identifier, NonNullableRepository } from "../src"; 3 | 4 | describe("NonNullableRepository", () => { 5 | describe("#get", () => { 6 | it("should return last used entity", () => { 7 | class AIdentifier extends Identifier {} 8 | 9 | interface AProps { 10 | id: AIdentifier; 11 | } 12 | 13 | class AEntity extends Entity {} 14 | 15 | class ARepository extends NonNullableRepository {} 16 | 17 | const defaultEntity = new AEntity({ 18 | id: new AIdentifier("a") 19 | }); 20 | const aRepository = new ARepository(defaultEntity); 21 | assert.ok(aRepository.get().equals(defaultEntity)); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/ValueObject.ts: -------------------------------------------------------------------------------- 1 | import { shallowEqual } from "shallow-equal-object"; 2 | 3 | export interface ValueObjectProps { 4 | [index: string]: any; 5 | } 6 | 7 | /** 8 | * Value object definition. 9 | * props is readonly by design. 10 | */ 11 | export class ValueObject { 12 | props: Readonly; 13 | 14 | constructor(props: Props) { 15 | this.props = Object.freeze(props); 16 | } 17 | 18 | /** 19 | * Check equality by shallow equals of properties. 20 | * It can be override. 21 | */ 22 | equals(object?: ValueObject): boolean { 23 | if (object === null || object === undefined) { 24 | return false; 25 | } 26 | if (object.props === undefined) { 27 | return false; 28 | } 29 | return shallowEqual(this.props, object.props); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // MIT © 2017 azu 2 | export { Identifier } from "./Identifier"; 3 | export { Entity } from "./Entity"; 4 | export { ValueObject } from "./ValueObject"; 5 | export { NonNullableRepository } from "./repository/NonNullableRepository"; 6 | export { NullableRepository } from "./repository/NullableRepository"; 7 | export { RepositoryCore } from "./repository/RepositoryCore"; 8 | export { Repository } from "./repository/Repository"; 9 | export { 10 | RepositoryEventEmitter, 11 | RepositoryDeletedEvent, 12 | RepositorySavedEvent, 13 | RepositoryEvents 14 | } from "./repository/RepositoryEventEmitter"; 15 | 16 | export { createConverter, Converter } from "./Converter"; 17 | // @deprecated 18 | // Use `createConverter` instead of it 19 | export { Serializer } from "./Serializer"; 20 | // status: Experimental 21 | // mixin 22 | export { Copyable } from "./mixin/Copyable"; 23 | -------------------------------------------------------------------------------- /src/Identifier.ts: -------------------------------------------------------------------------------- 1 | // MIT © 2017 azu 2 | /** 3 | * Identifier for Entity 4 | * 5 | * ## Example 6 | * 7 | * ```ts 8 | * class MyEntityIdentifier extends Identifier{} 9 | * ``` 10 | */ 11 | export class Identifier { 12 | constructor(private value: T) { 13 | this.value = value; 14 | } 15 | 16 | equals(id?: Identifier): boolean { 17 | if (id === null || id === undefined) { 18 | return false; 19 | } 20 | if (!(id instanceof this.constructor)) { 21 | return false; 22 | } 23 | return id.toValue() === this.value; 24 | } 25 | 26 | toString() { 27 | const constructorName = this.constructor.name; 28 | return `${constructorName}(${String(this.value)})`; 29 | } 30 | 31 | /** 32 | * Return raw value of identifier 33 | */ 34 | toValue(): T { 35 | return this.value; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "newLine": "LF", 7 | "outDir": "./lib/", 8 | "target": "es5", 9 | "sourceMap": true, 10 | "declaration": true, 11 | "jsx": "preserve", 12 | "lib": [ 13 | "es2017", 14 | "dom" 15 | ], 16 | /* Strict Type-Checking Options */ 17 | "strict": true, 18 | /* Additional Checks */ 19 | "noUnusedLocals": true, 20 | /* Report errors on unused locals. */ 21 | "noUnusedParameters": true, 22 | /* Report errors on unused parameters. */ 23 | "noImplicitReturns": true, 24 | /* Report error when not all code paths in function return a value. */ 25 | "noFallthroughCasesInSwitch": true 26 | /* Report errors for fallthrough cases in switch statement. */ 27 | }, 28 | "include": [ 29 | "src/**/*" 30 | ], 31 | "exclude": [ 32 | ".git", 33 | "node_modules" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 azu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /test/ValueObject-test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { ValueObject } from "../src"; 3 | 4 | type XProps = { value: number }; 5 | 6 | class XValue extends ValueObject { 7 | constructor(props: XProps) { 8 | super(props); 9 | } 10 | } 11 | 12 | describe("ValueObject", () => { 13 | describe("#equals", () => { 14 | it("when has same id, should return true", () => { 15 | const x1 = new XValue({ 16 | value: 42 17 | }); 18 | const x2 = new XValue({ 19 | value: 42 20 | }); 21 | assert.ok(x1.equals(x2), "x1 === x2"); 22 | }); 23 | it("when has not same id, should return false", () => { 24 | const x1 = new XValue({ 25 | value: 2 26 | }); 27 | const x2 = new XValue({ 28 | value: 4 29 | }); 30 | const x3 = { 31 | value: 4 32 | }; 33 | assert.ok(!x1.equals(x2), "x1 !== x2"); 34 | assert.ok(!x1.equals(x3 as any), "x1 !== x3"); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/Identifier-test.ts: -------------------------------------------------------------------------------- 1 | import { Identifier } from "../src/Identifier"; 2 | import * as assert from "assert"; 3 | 4 | class AIdentifier extends Identifier {} 5 | 6 | class BIdentifier extends Identifier {} 7 | 8 | describe("Identifier", () => { 9 | describe("#equals", () => { 10 | it("When Same Type and Same Value, return true", () => { 11 | const aId = new AIdentifier("id"); 12 | const bId = new AIdentifier("id"); 13 | assert.ok(aId.equals(bId), "a === b"); 14 | }); 15 | it("When Same Type and Difference Value, return false", () => { 16 | const aId = new AIdentifier("a"); 17 | const bId = new AIdentifier("b"); 18 | assert.ok(!aId.equals(bId), "a !== b"); 19 | }); 20 | 21 | it("When Difference Type and Same Value, return false", () => { 22 | const aId = new AIdentifier("id"); 23 | const bId = new BIdentifier("id"); 24 | assert.ok(!aId.equals(bId), "a !== b"); 25 | }); 26 | }); 27 | describe("#toString", () => { 28 | it("return human readable string", () => { 29 | const aId = new AIdentifier("id"); 30 | assert.strictEqual(aId.toString(), "AIdentifier(id)"); 31 | }); 32 | }); 33 | describe("#toValue", () => { 34 | it("return raw string", () => { 35 | const aId = new AIdentifier("id"); 36 | assert.strictEqual(aId.toValue(), "id"); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/repository/NullableRepository.ts: -------------------------------------------------------------------------------- 1 | // MIT © 2017 azu 2 | import { RepositoryCore } from "./RepositoryCore"; 3 | import { MapLike } from "map-like"; 4 | import { RepositoryEventEmitter } from "./RepositoryEventEmitter"; 5 | import { EntityLike } from "../EntityLike"; 6 | import { Repository } from "./Repository"; 7 | 8 | /** 9 | * NullableRepository has not initial value. 10 | * In other word, NullableRepository#get may return undefined. 11 | */ 12 | export class NullableRepository< 13 | Entity extends EntityLike, 14 | Props extends Entity["props"] = Entity["props"], 15 | Id extends Props["id"] = Props["id"] 16 | > implements Repository { 17 | private core: RepositoryCore; 18 | 19 | constructor() { 20 | this.core = new RepositoryCore(new MapLike()); 21 | } 22 | 23 | get map(): MapLike { 24 | return this.core.map; 25 | } 26 | 27 | get events(): RepositoryEventEmitter { 28 | return this.core.events; 29 | } 30 | 31 | get(): Entity | undefined { 32 | return this.core.getLastSaved(); 33 | } 34 | 35 | getAll(): Entity[] { 36 | return this.core.getAll(); 37 | } 38 | 39 | findById(entityId?: Id): Entity | undefined { 40 | return this.core.findById(entityId); 41 | } 42 | 43 | save(entity: Entity): void { 44 | this.core.save(entity); 45 | } 46 | 47 | delete(entity: Entity) { 48 | this.core.delete(entity); 49 | } 50 | 51 | clear(): void { 52 | this.core.clear(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/repository/NonNullableRepository.ts: -------------------------------------------------------------------------------- 1 | // MIT © 2017 azu 2 | import { RepositoryCore } from "./RepositoryCore"; 3 | import { MapLike } from "map-like"; 4 | import { RepositoryEventEmitter } from "./RepositoryEventEmitter"; 5 | import { EntityLike } from "../EntityLike"; 6 | import { Repository } from "./Repository"; 7 | 8 | /** 9 | * NonNullableRepository has initial value. 10 | * In other words, NonNullableRepository#get always return a value. 11 | */ 12 | export class NonNullableRepository< 13 | Entity extends EntityLike, 14 | Props extends Entity["props"] = Entity["props"], 15 | Id extends Props["id"] = Props["id"] 16 | > implements Repository { 17 | private core: RepositoryCore; 18 | constructor(protected initialEntity: Entity) { 19 | this.core = new RepositoryCore(new MapLike()); 20 | } 21 | 22 | get map(): MapLike { 23 | return this.core.map; 24 | } 25 | 26 | get events(): RepositoryEventEmitter { 27 | return this.core.events; 28 | } 29 | 30 | get(): Entity { 31 | return this.core.getLastSaved() || this.initialEntity; 32 | } 33 | 34 | getAll(): Entity[] { 35 | return this.core.getAll(); 36 | } 37 | 38 | findById(entityId?: Id): Entity | undefined { 39 | return this.core.findById(entityId); 40 | } 41 | 42 | save(entity: Entity): void { 43 | this.core.save(entity); 44 | } 45 | 46 | delete(entity: Entity) { 47 | this.core.delete(entity); 48 | } 49 | 50 | clear(): void { 51 | this.core.clear(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "directories": { 3 | "lib": "lib", 4 | "test": "test" 5 | }, 6 | "author": "azu", 7 | "license": "MIT", 8 | "files": [ 9 | "bin/", 10 | "lib/", 11 | "src/" 12 | ], 13 | "name": "ddd-base", 14 | "version": "0.9.1", 15 | "description": "DDD base class library for JavaScript application.", 16 | "main": "lib/index.js", 17 | "types": "lib/index.d.ts", 18 | "scripts": { 19 | "prettier": "prettier --write '{src,test}/**/*.ts'", 20 | "test": "mocha 'test/*.ts'", 21 | "build": "cross-env NODE_ENV=production tsc -p .", 22 | "watch": "tsc -p . --watch", 23 | "prepublish": "npm run clean && npm run build", 24 | "clean": "rimraf lib/" 25 | }, 26 | "lint-staged": { 27 | "*.{js,ts,tsx,css}": [ 28 | "prettier --write", 29 | "git add" 30 | ] 31 | }, 32 | "keywords": [ 33 | "ddd", 34 | "util", 35 | "base", 36 | "class" 37 | ], 38 | "repository": { 39 | "type": "git", 40 | "url": "https://github.com/almin/ddd-base.git" 41 | }, 42 | "bugs": { 43 | "url": "https://github.com/almin/ddd-base/issues" 44 | }, 45 | "homepage": "https://github.com/almin/ddd-base", 46 | "devDependencies": { 47 | "@types/mocha": "^5.2.7", 48 | "@types/node": "^10.17.60", 49 | "cross-env": "^6.0.3", 50 | "husky": "^4.3.8", 51 | "lerna": "^3.22.1", 52 | "lint-staged": "^7.3.0", 53 | "mocha": "^6.2.3", 54 | "prettier": "1.14.3", 55 | "rimraf": "^3.0.2", 56 | "ts-node": "^7.0.1", 57 | "typescript": "^3.1.3" 58 | }, 59 | "dependencies": { 60 | "map-like": "^2.0.0", 61 | "shallow-equal-object": "^1.1.1" 62 | }, 63 | "husky": { 64 | "hooks": { 65 | "post-commit": "git reset", 66 | "pre-commit": "lint-staged" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/Entitiy-test.ts: -------------------------------------------------------------------------------- 1 | // MIT © 2017 azu 2 | import { Identifier } from "../src/Identifier"; 3 | import { Entity } from "../src/Entity"; 4 | import * as assert from "assert"; 5 | 6 | // Entity A 7 | class AIdentifier extends Identifier {} 8 | 9 | interface AProps { 10 | id: AIdentifier; 11 | } 12 | 13 | class AEntity extends Entity {} 14 | 15 | // Entity B 16 | class BIdentifier extends Identifier {} 17 | 18 | interface BProps { 19 | id: BIdentifier; 20 | } 21 | 22 | class BEntity extends Entity {} 23 | 24 | describe("Entity", () => { 25 | describe("id", () => { 26 | it("should return id", () => { 27 | const aIdentifier = new AIdentifier("a-id"); 28 | const a1 = new AEntity({ 29 | id: aIdentifier 30 | }); 31 | assert.strictEqual(a1.props.id, aIdentifier); 32 | }); 33 | }); 34 | describe("#equals", () => { 35 | it("when has same id, should return true", () => { 36 | const a1 = new AEntity({ id: new AIdentifier("a-id") }); 37 | const a2 = new AEntity({ id: new AIdentifier("a-id") }); 38 | assert.ok(a1.equals(a2), "a1 === a2"); 39 | }); 40 | it("when has not same id, should return false", () => { 41 | const a1 = new AEntity({ id: new AIdentifier("a1-id") }); 42 | const a2 = new AEntity({ id: new AIdentifier("a2-id") }); 43 | assert.ok(!a1.equals(a2), "a1 !== a2"); 44 | }); 45 | it("A is not B", () => { 46 | const a = new AEntity({ id: new AIdentifier("1") }); 47 | const b = new BEntity({ 48 | id: new BIdentifier("1") 49 | }); 50 | assert.ok(!a.equals(b), "A is not B"); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/repository/RepositoryEventEmitter.ts: -------------------------------------------------------------------------------- 1 | // MIT © 2017 azu 2 | import { EventEmitter } from "events"; 3 | import { Entity } from "../Entity"; 4 | import { EntityLike } from "../EntityLike"; 5 | 6 | const SAVE = "SAVE"; 7 | const DELETE = "DELETE"; 8 | 9 | export class RepositorySavedEvent { 10 | readonly type = SAVE; 11 | 12 | constructor(public entity: T) {} 13 | } 14 | 15 | export class RepositoryDeletedEvent { 16 | readonly type = DELETE; 17 | 18 | constructor(public entity: T) {} 19 | } 20 | 21 | export type RepositoryEvents> = RepositorySavedEvent | RepositoryDeletedEvent; 22 | 23 | export class RepositoryEventEmitter> { 24 | private eventEmitter: EventEmitter; 25 | 26 | constructor() { 27 | this.eventEmitter = new EventEmitter(); 28 | // disable EventEmitter Warning 29 | this.eventEmitter.setMaxListeners(0); 30 | } 31 | 32 | emit(event: RepositoryEvents) { 33 | this.eventEmitter.emit(event.type, event); 34 | } 35 | 36 | onChange(handler: (event: RepositoryEvents) => void): () => void { 37 | const releaseHandlers = [this.onSave(handler), this.onDelete(handler)]; 38 | return () => { 39 | releaseHandlers.forEach(event => { 40 | event(); 41 | }); 42 | }; 43 | } 44 | 45 | onSave(handler: (event: RepositorySavedEvent) => void): () => void { 46 | this.eventEmitter.on(SAVE, handler); 47 | return () => { 48 | return this.eventEmitter.removeListener(SAVE, handler); 49 | }; 50 | } 51 | 52 | onDelete(handler: (event: RepositoryDeletedEvent) => void): () => void { 53 | this.eventEmitter.on(DELETE, handler); 54 | return () => { 55 | return this.eventEmitter.removeListener(DELETE, handler); 56 | }; 57 | } 58 | 59 | clear() { 60 | return this.eventEmitter.removeAllListeners(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/608690d6b9a78c2a003affc792e49a84905b3118/Node.gitignore 2 | 3 | # Logs 4 | logs 5 | *.log 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # node-waf configuration 22 | .lock-wscript 23 | 24 | # Compiled binary addons (http://nodejs.org/api/addons.html) 25 | build/Release 26 | 27 | # Dependency directory 28 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 29 | node_modules 30 | 31 | # Debug log from npm 32 | npm-debug.log 33 | 34 | 35 | ### https://raw.github.com/github/gitignore/608690d6b9a78c2a003affc792e49a84905b3118/Global/JetBrains.gitignore 36 | 37 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 38 | 39 | *.iml 40 | 41 | ## Directory-based project format: 42 | .idea/ 43 | # if you remove the above rule, at least ignore the following: 44 | 45 | # User-specific stuff: 46 | # .idea/workspace.xml 47 | # .idea/tasks.xml 48 | # .idea/dictionaries 49 | 50 | # Sensitive or high-churn files: 51 | # .idea/dataSources.ids 52 | # .idea/dataSources.xml 53 | # .idea/sqlDataSources.xml 54 | # .idea/dynamic.xml 55 | # .idea/uiDesigner.xml 56 | 57 | # Gradle: 58 | # .idea/gradle.xml 59 | # .idea/libraries 60 | 61 | # Mongo Explorer plugin: 62 | # .idea/mongoSettings.xml 63 | 64 | ## File-based project format: 65 | *.ipr 66 | *.iws 67 | 68 | ## Plugin-specific files: 69 | 70 | # IntelliJ 71 | out/ 72 | 73 | # mpeltonen/sbt-idea plugin 74 | .idea_modules/ 75 | 76 | # JIRA plugin 77 | atlassian-ide-plugin.xml 78 | 79 | # Crashlytics plugin (for Android Studio and IntelliJ) 80 | com_crashlytics_export_strings.xml 81 | crashlytics.properties 82 | crashlytics-build.properties 83 | 84 | 85 | /lib 86 | -------------------------------------------------------------------------------- /src/Entity.ts: -------------------------------------------------------------------------------- 1 | // MIT © 2017 azu 2 | import { Identifier } from "./Identifier"; 3 | import { EntityLike, EntityLikeProps } from "./EntityLike"; 4 | 5 | export const isEntity = (v: any): v is Entity => { 6 | return v instanceof Entity; 7 | }; 8 | 9 | /** 10 | * Entity has readonly props. 11 | * It is created with Props and It has `props` property. 12 | * This entity has received through constructor. 13 | * 14 | * ## Why readonly props? 15 | * 16 | * If you want to modify props directory, you should define entity properties. 17 | * 18 | * Entity require `props` object as `super(props)`. 19 | * Also, you can define `this.name = props.name` as own property. 20 | * 21 | * This limitation ensure `props` value 22 | * 23 | * ```ts 24 | * class ShoppingCartItemIdentifier extends Identifier { 25 | * } 26 | * 27 | * interface ShoppingCartItemProps { 28 | * id: ShoppingCartItemIdentifier; 29 | * name: string; 30 | * price: number; 31 | * } 32 | * 33 | * class ShoppingCartItem extends Entity implements ShoppingCartItemProps { 34 | * id: ShoppingCartItemIdentifier; 35 | * name: string; 36 | * price: number; 37 | * 38 | * constructor(props: ShoppingCartItemProps) { 39 | * super(props); 40 | * this.id = props.id; 41 | * this.name = props.name; 42 | * this.price = props.price; 43 | * } 44 | * } 45 | * ``` 46 | */ 47 | export class Entity>> implements EntityLike { 48 | props: Readonly; 49 | 50 | constructor(props: Props) { 51 | this.props = Object.freeze(props); 52 | } 53 | 54 | /* 55 | * Check equality by identifier 56 | */ 57 | equals(object?: Entity): boolean { 58 | if (object == null || object == undefined) { 59 | return false; 60 | } 61 | if (this === object) { 62 | return true; 63 | } 64 | if (!isEntity(object)) { 65 | return false; 66 | } 67 | return this.props.id.equals(object.props.id); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/Serializer-test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { Identifier, Serializer, Entity } from "../src"; 3 | 4 | describe("Serializer", () => { 5 | // Entity A 6 | class AIdentifier extends Identifier {} 7 | 8 | interface AProps { 9 | id: AIdentifier; 10 | a: number; 11 | b: string; 12 | } 13 | 14 | class AEntity extends Entity { 15 | constructor(args: AProps) { 16 | super(args); 17 | } 18 | 19 | toJSON(): AEntityJSON { 20 | return { 21 | id: this.props.id.toValue(), 22 | a: this.props.a, 23 | b: this.props.b 24 | }; 25 | } 26 | } 27 | 28 | interface AEntityJSON { 29 | id: string; 30 | a: number; 31 | b: string; 32 | } 33 | 34 | const ASerializer: Serializer = { 35 | fromJSON(json) { 36 | return new AEntity({ 37 | id: new AIdentifier(json.id), 38 | a: json.a, 39 | b: json.b 40 | }); 41 | }, 42 | toJSON(entity) { 43 | return entity.toJSON(); 44 | } 45 | }; 46 | 47 | it("toJSON: Entity -> JSON", () => { 48 | const entity = new AEntity({ 49 | id: new AIdentifier("a"), 50 | a: 42, 51 | b: "b prop" 52 | }); 53 | 54 | const json = ASerializer.toJSON(entity); 55 | assert.deepStrictEqual(json, { 56 | id: "a", 57 | a: 42, 58 | b: "b prop" 59 | }); 60 | }); 61 | 62 | it("fromJSON: JSON -> Entity", () => { 63 | const entity = ASerializer.fromJSON({ 64 | id: "a", 65 | a: 42, 66 | b: "b prop" 67 | }); 68 | assert.ok(entity instanceof AEntity, "entity should be instanceof AEntity"); 69 | assert.deepStrictEqual( 70 | ASerializer.toJSON(entity), 71 | { 72 | id: "a", 73 | a: 42, 74 | b: "b prop" 75 | }, 76 | "JSON <-> Entity" 77 | ); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/Domain-scenario-test.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Identifier, ValueObject } from "../src"; 2 | import * as assert from "assert"; 3 | 4 | class ShoppingCartItemIdentifier extends Identifier {} 5 | 6 | interface ShoppingCartItemProps { 7 | id: ShoppingCartItemIdentifier; 8 | name: string; 9 | price: number; 10 | } 11 | 12 | class ShoppingCartItem extends Entity implements ShoppingCartItemProps { 13 | id: ShoppingCartItemIdentifier; 14 | name: string; 15 | price: number; 16 | 17 | constructor(props: ShoppingCartItemProps) { 18 | super(props); 19 | this.id = props.id; 20 | this.name = props.name; 21 | this.price = props.price; 22 | } 23 | } 24 | 25 | interface ShoppingCartItemCollectionProps { 26 | items: ShoppingCartItem[]; 27 | } 28 | 29 | class ShoppingCartItemCollection extends ValueObject 30 | implements ShoppingCartItemCollectionProps { 31 | items: ShoppingCartItem[]; 32 | 33 | constructor(props: ShoppingCartItemCollectionProps) { 34 | super(props); 35 | this.items = props.items; 36 | } 37 | } 38 | 39 | class ShoppingIdentifier extends Identifier {} 40 | 41 | interface ShoppingCartProps { 42 | id: ShoppingIdentifier; 43 | itemsCollection: ShoppingCartItemCollection; 44 | } 45 | 46 | class ShoppingCart extends Entity implements ShoppingCartProps { 47 | id: ShoppingIdentifier; 48 | itemsCollection: ShoppingCartItemCollection; 49 | 50 | constructor(props: ShoppingCartProps) { 51 | super(props); 52 | this.id = props.id; 53 | this.itemsCollection = props.itemsCollection; 54 | } 55 | } 56 | 57 | describe("Domain-scenario", () => { 58 | it("should have own property and props", () => { 59 | const shoppingCart = new ShoppingCart({ 60 | id: new ShoppingCartItemIdentifier("shopping-cart"), 61 | itemsCollection: new ShoppingCartItemCollection({ 62 | items: [] 63 | }) 64 | }); 65 | assert.ok(Array.isArray(shoppingCart.itemsCollection.items)); 66 | assert.strictEqual(shoppingCart.itemsCollection.items, shoppingCart.props.itemsCollection.props.items); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/mixin/Copyable.ts: -------------------------------------------------------------------------------- 1 | import { Identifier } from "../Identifier"; 2 | import { EntityLikeProps } from "../EntityLike"; 3 | import { EntityLike } from "../EntityLike"; 4 | import { Constructor } from "../TypeUtil"; 5 | 6 | /** 7 | * 8 | * K is type of a key 9 | * T[K] is type of its value 10 | */ 11 | export type PartialMap = { [K in keyof T]?: (prev: T[K]) => T[K] }; 12 | 13 | /** 14 | * Mixin Copyable to the BaseClass 15 | * @param BaseClass 16 | * @returns Copyable class 17 | * @example 18 | * 19 | * class A extends Copyable>{} 20 | * 21 | * @see https://github.com/Microsoft/TypeScript/issues/22431#issuecomment-371908767 22 | */ 23 | export const Copyable = < 24 | ConstructorClass extends Constructor>>>, 25 | Entity extends InstanceType, 26 | Props extends Entity["props"] 27 | >( 28 | BaseClass: ConstructorClass 29 | ) => { 30 | return class extends BaseClass { 31 | /** 32 | * Return partial change of this object 33 | * 34 | * Example: 35 | * ```js 36 | * new Person({ 37 | * name "jack", 38 | * age: 2 39 | * }).copy({age: 10})` is `new Person({ 40 | * name: "jack", 41 | * age: 10 42 | * }) 43 | * ``` 44 | */ 45 | copy(partial: Partial): this { 46 | const newProps = Object.assign({}, this.props, partial); 47 | const Construct = this.constructor as any; 48 | return new Construct(newProps) as this; 49 | } 50 | 51 | /** 52 | * Return partial change of this object by using functions 53 | * 54 | * Example: 55 | * ```js 56 | * new Person({ 57 | * name "jack", 58 | * age: 2 59 | * }).mapCopy({age: prev => prev+1})` is `new Person({ 60 | * name: "jack", 61 | * age: 3 62 | * }) 63 | * ``` 64 | */ 65 | mapCopy(partial: PartialMap): this { 66 | const newProps: { [index: string]: any } = {}; 67 | const oldInstance: { [index: string]: any } = this; 68 | Object.keys(this.props).forEach(key => { 69 | if (key in partial) { 70 | newProps[key] = (partial as any)[key](oldInstance[key]); 71 | } else { 72 | newProps[key] = oldInstance[key]; 73 | } 74 | }); 75 | const Construct = this.constructor as any; 76 | return new Construct(newProps) as this; 77 | } 78 | }; 79 | }; 80 | -------------------------------------------------------------------------------- /src/repository/RepositoryCore.ts: -------------------------------------------------------------------------------- 1 | // MIT © 2017 azu 2 | import { MapLike } from "map-like"; 3 | import { RepositoryDeletedEvent, RepositoryEventEmitter, RepositorySavedEvent } from "./RepositoryEventEmitter"; 4 | import { EntityLike } from "../EntityLike"; 5 | 6 | /** 7 | * Repository Core implementation 8 | */ 9 | export class RepositoryCore< 10 | Entity extends EntityLike, 11 | Props extends Entity["props"] = Entity["props"], 12 | Id extends Props["id"] = Props["id"] 13 | > { 14 | public readonly map: MapLike; 15 | public readonly events: RepositoryEventEmitter; 16 | private lastUsed: Entity | undefined; 17 | 18 | constructor(map: MapLike) { 19 | this.map = map; 20 | this.events = new RepositoryEventEmitter(); 21 | } 22 | 23 | /** 24 | * Get last saved entity if exist. 25 | * This is useful on client-side implementation. 26 | * Because, client-side often access-user is a single user. 27 | */ 28 | getLastSaved(): Entity | undefined { 29 | return this.lastUsed; 30 | } 31 | 32 | /** 33 | * Find a entity by `entityIdentifier` that is instance of Identifier class. 34 | * Return `undefined` if not found entity. 35 | */ 36 | findById(entityIdentifier?: Id): Entity | undefined { 37 | if (!entityIdentifier) { 38 | return; 39 | } 40 | return this.map.get(String(entityIdentifier.toValue())); 41 | } 42 | 43 | /** 44 | * Find all entity that `predicate(entity)` return true 45 | */ 46 | findAll(predicate: (entity: Entity) => boolean): Entity[] { 47 | return this.map.values().filter(predicate); 48 | } 49 | 50 | /** 51 | * Get all entities 52 | */ 53 | getAll(): Entity[] { 54 | return this.map.values(); 55 | } 56 | 57 | /** 58 | * Save entity to the repository. 59 | */ 60 | save(entity: Entity): void { 61 | this.lastUsed = entity; 62 | this.map.set(String(entity.props.id.toValue()), entity); 63 | this.events.emit(new RepositorySavedEvent(entity)); 64 | } 65 | 66 | /** 67 | * Delete entity from the repository. 68 | */ 69 | delete(entity: Entity) { 70 | this.map.delete(String(entity.props.id.toValue())); 71 | if (this.lastUsed === entity) { 72 | delete this.lastUsed; 73 | } 74 | this.events.emit(new RepositoryDeletedEvent(entity)); 75 | } 76 | 77 | /** 78 | * Delete entity by `entityIdentifier` that is instance of Identifier class. 79 | */ 80 | deleteById(entityIdentifier?: Id) { 81 | if (!entityIdentifier) { 82 | return; 83 | } 84 | const entity = this.findById(entityIdentifier); 85 | if (entity) { 86 | this.delete(entity); 87 | } 88 | } 89 | 90 | /** 91 | * Clear all entity 92 | */ 93 | clear(): void { 94 | this.map.forEach(entity => this.delete(entity)); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /test/Copyable-test.ts: -------------------------------------------------------------------------------- 1 | // Entity A 2 | import { Entity, Identifier } from "../src"; 3 | import { Copyable } from "../src/mixin/Copyable"; 4 | import * as assert from "assert"; 5 | 6 | describe("Copyable", () => { 7 | it("should inherit Mixin CLass", () => { 8 | class AIdentifier extends Identifier {} 9 | 10 | interface AProps { 11 | id: AIdentifier; 12 | value: string; 13 | } 14 | 15 | class AEntity extends Entity { 16 | rootValue: string = "rootValue"; 17 | } 18 | 19 | const aEntity = new AEntity({ 20 | id: new AIdentifier("a"), 21 | value: "a" 22 | }); 23 | assert.ok(aEntity instanceof AEntity); 24 | assert.strictEqual(aEntity.props.value, "a"); 25 | assert.strictEqual(aEntity.rootValue, "rootValue"); 26 | 27 | class CopyableEntity extends Copyable(AEntity) { 28 | cProps = "c"; 29 | } 30 | 31 | const entity = new CopyableEntity({ 32 | id: new AIdentifier("test"), 33 | value: "string" 34 | }); 35 | assert.ok(entity instanceof CopyableEntity); 36 | assert.strictEqual(entity.props.value, "string"); 37 | assert.strictEqual(entity.cProps, "c"); 38 | assert.strictEqual(entity.rootValue, "rootValue"); 39 | const newEntity = entity.copy({ 40 | value: "new" 41 | }); 42 | assert.ok(newEntity instanceof CopyableEntity); 43 | assert.strictEqual(newEntity.props.value, "new"); 44 | assert.strictEqual(newEntity.cProps, "c"); 45 | assert.strictEqual(newEntity.rootValue, "rootValue"); 46 | }); 47 | describe("#copy", () => { 48 | it("should copy prop partially", () => { 49 | class AIdentifier extends Identifier {} 50 | 51 | interface AProps { 52 | id: AIdentifier; 53 | value: string; 54 | } 55 | 56 | class AEntity extends Entity {} 57 | 58 | class CopyableEntity extends Copyable(AEntity) {} 59 | 60 | const entity = new CopyableEntity({ 61 | id: new AIdentifier("test"), 62 | value: "string" 63 | }); 64 | const newEntity = entity.copy({ 65 | value: "new" 66 | }); 67 | assert.strictEqual(newEntity.props.id, entity.props.id); 68 | assert.strictEqual(newEntity.props.value, "new"); 69 | }); 70 | }); 71 | describe("#mapCopy", () => { 72 | it("should map and copy", () => { 73 | it("should copy prop partially", () => { 74 | class AIdentifier extends Identifier {} 75 | 76 | interface AProps { 77 | id: AIdentifier; 78 | value: string; 79 | } 80 | 81 | class AEntity extends Entity {} 82 | 83 | class CopyableEntity extends Copyable(AEntity) {} 84 | 85 | const entity = new CopyableEntity({ 86 | id: new AIdentifier("test"), 87 | value: "string" 88 | }); 89 | const newEntity = entity 90 | .mapCopy({ 91 | value: prevValue => { 92 | assert.strictEqual(prevValue, "value"); 93 | return "a"; 94 | } 95 | }) 96 | .mapCopy({ 97 | value: prevValue => { 98 | assert.strictEqual(prevValue, "a"); 99 | return "b"; 100 | } 101 | }); 102 | assert.strictEqual(newEntity.props.id, entity.props.id); 103 | assert.strictEqual(newEntity.props.value, "b"); 104 | }); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/Converter-test.ts: -------------------------------------------------------------------------------- 1 | import assert = require("assert"); 2 | import { createConverter, Entity, Identifier, ValueObject } from "../src"; 3 | 4 | // Entity A 5 | class AIdentifier extends Identifier {} 6 | 7 | interface AProps { 8 | id: AIdentifier; 9 | a1: number; 10 | a2: string; 11 | } 12 | 13 | class AEntity extends Entity { 14 | constructor(args: AProps) { 15 | super(args); 16 | } 17 | } 18 | 19 | interface AEntityJSON { 20 | id: string; 21 | a1: number; 22 | a2: string; 23 | } 24 | 25 | const AConverter = createConverter(AEntity, { 26 | id: [prop => prop.toValue(), json => new AIdentifier(json)], 27 | a1: [prop => prop, json => json], 28 | a2: [prop => prop, json => json] 29 | }); 30 | 31 | // ValueObject B 32 | interface BValueProps { 33 | b1: number; 34 | b2: string; 35 | } 36 | 37 | class BValue extends ValueObject {} 38 | 39 | interface BValueJSON { 40 | b1: number; 41 | b2: string; 42 | } 43 | 44 | const BConverter = createConverter(BValue, { 45 | b1: [prop => prop, json => json], 46 | b2: [prop => prop, json => json] 47 | }); 48 | 49 | // Parent has A and B 50 | class ParentIdentifier extends Identifier {} 51 | 52 | interface ParentJSON { 53 | id: string; 54 | a: AEntityJSON; 55 | b: BValueJSON; 56 | } 57 | 58 | interface ParentProps { 59 | id: ParentIdentifier; 60 | a: AEntity; 61 | b: BValue; 62 | } 63 | 64 | class ParentEntity extends Entity {} 65 | 66 | const ParentConverter = createConverter(ParentEntity, { 67 | id: [prop => prop.toValue(), json => new ParentIdentifier(json)], 68 | a: AConverter, 69 | b: BConverter 70 | }); 71 | 72 | describe("Converter", function() { 73 | it("should convert JSON <-> Entity", () => { 74 | const converter = createConverter(AEntity, { 75 | id: [prop => prop.toValue(), json => new AIdentifier(json)], 76 | a1: [prop => prop, json => json], 77 | a2: [prop => prop, json => json] 78 | }); 79 | const entity = new AEntity({ 80 | id: new AIdentifier("a"), 81 | a1: 42, 82 | a2: "b prop" 83 | }); 84 | const json = converter.toJSON(entity); 85 | assert.deepStrictEqual(json, { 86 | id: "a", 87 | a1: 42, 88 | a2: "b prop" 89 | }); 90 | const entity2 = converter.fromJSON(json); 91 | assert.deepStrictEqual(entity, entity2); 92 | }); 93 | it("should convert Props <-> Entity", () => { 94 | const entity = new AEntity({ 95 | id: new AIdentifier("a"), 96 | a1: 42, 97 | a2: "b prop" 98 | }); 99 | const json = AConverter.toJSON(entity); 100 | assert.deepStrictEqual(json, { 101 | id: "a", 102 | a1: 42, 103 | a2: "b prop" 104 | }); 105 | const props = AConverter.JSONToProps(json); 106 | assert.deepStrictEqual(props, entity.props); 107 | }); 108 | it("should convert JSON <-> ValueObject", () => { 109 | const value = new BValue({ 110 | b1: 42, 111 | b2: "b prop" 112 | }); 113 | const json = BConverter.toJSON(value); 114 | assert.deepStrictEqual(json, { 115 | b1: 42, 116 | b2: "b prop" 117 | }); 118 | const value2 = BConverter.fromJSON(json); 119 | assert.deepStrictEqual(value, value2); 120 | }); 121 | 122 | it("should convert by child Converter", () => { 123 | const a = new AEntity({ 124 | id: new AIdentifier("a"), 125 | a1: 42, 126 | a2: "b prop" 127 | }); 128 | const b = new BValue({ 129 | b1: 42, 130 | b2: "b prop" 131 | }); 132 | const parent = new ParentEntity({ 133 | id: new ParentIdentifier("parent"), 134 | a, 135 | b 136 | }); 137 | const parentJSON = ParentConverter.toJSON(parent); 138 | assert.deepStrictEqual(parentJSON, { 139 | id: "parent", 140 | a: { 141 | a1: 42, 142 | a2: "b prop", 143 | id: "a" 144 | }, 145 | b: { 146 | b1: 42, 147 | b2: "b prop" 148 | } 149 | }); 150 | const parent_2 = ParentConverter.fromJSON(parentJSON); 151 | assert.ok(parent.equals(parent_2)); 152 | assert.deepStrictEqual(parent, parent_2); 153 | const parentJSON_2 = ParentConverter.propsToJSON(parent.props); 154 | assert.deepStrictEqual(parentJSON, parentJSON_2); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /src/Converter.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from "./Entity"; 2 | import { ValueObject, ValueObjectProps } from "./ValueObject"; 3 | import { EntityLikeProps } from "./EntityLike"; 4 | import { Identifier } from "./Identifier"; 5 | import { Constructor } from "./TypeUtil"; 6 | 7 | /** 8 | * If pass EntityProps, return Entity type 9 | * If pass ValueObjectProps, return ValueObject type 10 | */ 11 | type CreateEntityOrValueObject | ValueObjectProps> = Props extends EntityLikeProps< 12 | any 13 | > 14 | ? Entity 15 | : ValueObject; 16 | 17 | export class Converter< 18 | Props extends EntityLikeProps> | ValueObjectProps, 19 | JSON extends { [P in keyof Props]: any }, 20 | T extends CreateEntityOrValueObject = CreateEntityOrValueObject 21 | > { 22 | private EntityConstructor: Constructor; 23 | private mapping: { 24 | [P in keyof Props]: 25 | | [((prop: Props[P]) => JSON[P]), ((json: JSON[P]) => Props[P])] 26 | | Converter 27 | }; 28 | 29 | constructor({ 30 | EntityConstructor, 31 | mapping 32 | }: { 33 | EntityConstructor: Constructor; 34 | mapping: { 35 | [P in keyof Props]: 36 | | [(prop: Props[P]) => JSON[P], (json: JSON[P]) => Props[P]] 37 | | Converter 38 | }; 39 | }) { 40 | this.EntityConstructor = EntityConstructor; 41 | this.mapping = mapping; 42 | } 43 | 44 | /** 45 | * Convert Entity|ValueObject to JSON format 46 | */ 47 | toJSON(entity: T): JSON { 48 | const json: any = {}; 49 | Object.keys(entity.props).forEach(key => { 50 | const mappingItem = this.mapping[key]; 51 | if (mappingItem instanceof Converter) { 52 | json[key] = mappingItem.toJSON(entity.props[key]); 53 | } else { 54 | json[key] = mappingItem[0](entity.props[key]); 55 | } 56 | }); 57 | return json; 58 | } 59 | 60 | /** 61 | * Convert Props to JSON 62 | */ 63 | propsToJSON(props: Props): JSON { 64 | const json: any = {}; 65 | Object.keys(props).forEach(key => { 66 | const mappingItem = this.mapping[key]; 67 | if (mappingItem instanceof Converter) { 68 | json[key] = mappingItem.toJSON(props[key]); 69 | } else { 70 | json[key] = mappingItem[0](props[key]); 71 | } 72 | }); 73 | return json; 74 | } 75 | 76 | /** 77 | * Convert JSON to Props 78 | */ 79 | JSONToProps(json: JSON): Props { 80 | const props: any = {}; 81 | Object.keys(json).forEach(key => { 82 | const mappingItem = this.mapping[key]; 83 | const jsonItem = (json as any)[key]; 84 | if (mappingItem instanceof Converter) { 85 | props[key] = mappingItem.JSONToProps(jsonItem); 86 | } else { 87 | props[key] = mappingItem[1](jsonItem); 88 | } 89 | }); 90 | return props as Props; 91 | } 92 | 93 | /** 94 | * Convert JSON to Entity|ValueObject 95 | */ 96 | fromJSON(json: JSON): T { 97 | const props: any = {}; 98 | Object.keys(json).forEach(key => { 99 | const mappingItem = this.mapping[key]; 100 | const jsonItem = (json as any)[key]; 101 | if (mappingItem instanceof Converter) { 102 | props[key] = mappingItem.fromJSON(jsonItem); 103 | } else { 104 | props[key] = mappingItem[1](jsonItem); 105 | } 106 | }); 107 | return new this.EntityConstructor(props as Props); 108 | } 109 | } 110 | 111 | /** 112 | * Create convert that convert JSON <-> Props <-> Entity. 113 | * Limitation: Convert can be possible one for one converting. 114 | * @param EntityConstructor 115 | * @param mappingPropsAndJSON 116 | */ 117 | export const createConverter = < 118 | Props extends EntityLikeProps> | ValueObjectProps, 119 | JSON extends { [P in keyof Props]: any }, 120 | T extends CreateEntityOrValueObject = CreateEntityOrValueObject, 121 | EntityConstructor extends Constructor = Constructor 122 | >( 123 | EntityConstructor: EntityConstructor, 124 | // Props[P] => Entity or ValueObject 125 | // JSON[P] => json property 126 | // Props[P].props => Entity's props or ValueObject's props 127 | mappingPropsAndJSON: { 128 | [P in keyof Props]: 129 | | [(prop: Props[P]) => JSON[P], (json: JSON[P]) => Props[P]] 130 | | Converter 131 | } 132 | ) => { 133 | return new Converter({ 134 | EntityConstructor, 135 | mapping: mappingPropsAndJSON 136 | }); 137 | }; 138 | -------------------------------------------------------------------------------- /test/RepositoryCore-test.ts: -------------------------------------------------------------------------------- 1 | // MIT © 2017 azu 2 | import { Entity } from "../src/Entity"; 3 | import { Identifier } from "../src/Identifier"; 4 | import { RepositoryCore } from "../src/repository/RepositoryCore"; 5 | import * as assert from "assert"; 6 | import { MapLike } from "map-like"; 7 | import { RepositoryDeletedEvent, RepositorySavedEvent } from "../src/repository/RepositoryEventEmitter"; 8 | 9 | class AIdentifier extends Identifier {} 10 | 11 | interface AEntityProps { 12 | id: AIdentifier; 13 | } 14 | 15 | class AEntity extends Entity {} 16 | 17 | describe("RepositoryCore", () => { 18 | describe("getLastSave", () => { 19 | it("should return lastSaved entity", () => { 20 | const repository = new RepositoryCore(new MapLike()); 21 | assert.strictEqual(repository.getLastSaved(), undefined, "return null by default"); 22 | // save entity 23 | const entity = new AEntity({ 24 | id: new AIdentifier("a") 25 | }); 26 | repository.save(entity); 27 | assert.strictEqual(repository.getLastSaved(), entity, "return entity that is saved at last"); 28 | // delete 29 | repository.delete(entity); 30 | assert.strictEqual(repository.getLastSaved(), undefined, "return null again"); 31 | }); 32 | }); 33 | describe("findById", () => { 34 | it("should return entity", () => { 35 | const repository = new RepositoryCore(new MapLike()); 36 | const entity = new AEntity({ 37 | id: new AIdentifier("a") 38 | }); 39 | repository.save(entity); 40 | // hit same id 41 | assert.strictEqual(repository.findById(entity.props.id), entity); 42 | }); 43 | it("when not found, should return undefined", () => { 44 | const repository = new RepositoryCore(new MapLike()); 45 | const entity = new AEntity({ 46 | id: new AIdentifier("a") 47 | }); 48 | // hit same id 49 | assert.strictEqual(repository.findById(entity.props.id), undefined); 50 | }); 51 | }); 52 | describe("findAll", () => { 53 | it("predicate receive entity", () => { 54 | const repository = new RepositoryCore(new MapLike()); 55 | const entity = new AEntity({ 56 | id: new AIdentifier("a") 57 | }); 58 | repository.save(entity); 59 | repository.findAll(entity => { 60 | assert.ok(entity instanceof AEntity); 61 | return true; 62 | }); 63 | }); 64 | it("should return entities", () => { 65 | const repository = new RepositoryCore(new MapLike()); 66 | const entity = new AEntity({ 67 | id: new AIdentifier("a") 68 | }); 69 | repository.save(entity); 70 | assert.deepStrictEqual(repository.findAll(() => true), [entity]); 71 | }); 72 | it("when not found, should return empty array", () => { 73 | const repository = new RepositoryCore(new MapLike()); 74 | const entity = new AEntity({ 75 | id: new AIdentifier("a") 76 | }); 77 | repository.save(entity); 78 | assert.deepStrictEqual(repository.findAll(() => false), []); 79 | }); 80 | }); 81 | describe("getAll", () => { 82 | it("should return all entity", () => { 83 | const repository = new RepositoryCore(new MapLike()); 84 | const entity = new AEntity({ 85 | id: new AIdentifier("a") 86 | }); 87 | repository.save(entity); 88 | assert.deepStrictEqual(repository.getAll(), [entity]); 89 | }); 90 | }); 91 | describe("delete", () => { 92 | it("delete entity, it to be not found", () => { 93 | const repository = new RepositoryCore(new MapLike()); 94 | const entity = new AEntity({ 95 | id: new AIdentifier("a") 96 | }); 97 | repository.save(entity); 98 | // delete 99 | repository.delete(entity); 100 | // not found 101 | assert.strictEqual(repository.findById(entity.props.id), undefined); 102 | }); 103 | }); 104 | describe("deleteById", () => { 105 | it("should delete by id", () => { 106 | it("delete entity, it to be not found", () => { 107 | const repository = new RepositoryCore(new MapLike()); 108 | const entity = new AEntity({ 109 | id: new AIdentifier("a") 110 | }); 111 | repository.save(entity); 112 | // delete 113 | repository.deleteById(entity.props.id); 114 | // not found 115 | assert.strictEqual(repository.findById(entity.props.id), undefined); 116 | }); 117 | }); 118 | }); 119 | describe("clear", () => { 120 | it("should clear all entity", () => { 121 | const repository = new RepositoryCore(new MapLike()); 122 | const entity = new AEntity({ 123 | id: new AIdentifier("a") 124 | }); 125 | repository.save(entity); 126 | repository.save(entity); 127 | repository.save(entity); 128 | assert.ok(repository.getAll().length > 0); 129 | repository.clear(); 130 | assert.ok(repository.getAll().length === 0, "should be cleared"); 131 | }); 132 | }); 133 | describe("events", () => { 134 | it("should emit Events", () => { 135 | const repository = new RepositoryCore(new MapLike()); 136 | const entity = new AEntity({ 137 | id: new AIdentifier("a") 138 | }); 139 | let count = 0; 140 | repository.events.onSave(event => { 141 | count++; 142 | assert.ok(event instanceof RepositorySavedEvent); 143 | assert.strictEqual(event.entity, entity); 144 | }); 145 | repository.events.onDelete(event => { 146 | count++; 147 | assert.ok(event instanceof RepositoryDeletedEvent); 148 | assert.strictEqual(event.entity, entity); 149 | }); 150 | repository.save(entity); 151 | repository.delete(entity); 152 | assert.strictEqual(count, 2); 153 | }); 154 | it("onChange is emitted when some is changed", () => { 155 | const repository = new RepositoryCore(new MapLike()); 156 | const entity = new AEntity({ 157 | id: new AIdentifier("a") 158 | }); 159 | let count = 0; 160 | repository.events.onChange(_ => { 161 | count++; 162 | }); 163 | repository.save(entity); 164 | repository.delete(entity); 165 | assert.strictEqual(count, 2); 166 | }); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ddd-base [![Build Status](https://travis-ci.org/almin/ddd-base.svg?branch=master)](https://travis-ci.org/almin/ddd-base) 2 | 3 | **Status**: Experimental 4 | 5 | DDD base class library for JavaScript client-side application. 6 | 7 | **Notes**: 8 | 9 | This library does not depend on [Almin](https://github.com/almin/almin). 10 | You can use it with other JavaScript framework. 11 | 12 | ## Features 13 | 14 | This library provide basic DDD base classes. 15 | 16 | - [`Entity`](#entity): Entity is domain concept that have a unique identity 17 | - [`Identifier`](#identifier): Identifier is unique identifier for an Entity 18 | - [`ValueObject`](#valueobject): Value Object is an entity’s state, describing something about the entity 19 | - [`Repository`](#repository): Repository is used to manage aggregate persistence 20 | - [`Converter`](#converter): Converter convert between Entity <-> Props(Entity's props) <-> JSON(Serialized data object) 21 | 22 | ## Install 23 | 24 | Install with [npm](https://www.npmjs.com/): 25 | 26 | npm install ddd-base 27 | 28 | ## Usage 29 | 30 | ### Entity 31 | 32 | > Entities are domain concepts that have a unique identity in the problem domain. 33 | 34 | Entity's equability is Identifier. 35 | 36 | ### Identifier 37 | 38 | Identifier is a unique object for each entity. 39 | 40 | `Entity#equals` check that the Entity's identifier is equaled to other entity's identifier. 41 | 42 | #### Entity Props 43 | 44 | Entity Props have to `id` props that is instance of `Identifier`. 45 | 46 | 1. Define `XProps` type 47 | - `XProps` should include `id: Identifier` property. 48 | 49 | ```ts 50 | class XIdentifier extends Identifier {} 51 | interface XProps { 52 | id: XIdentifier; // <= required 53 | } 54 | ``` 55 | 56 | 2. Pass `XProps` to `Entity` 57 | 58 | ```ts 59 | class XEntity extends Entity { 60 | // implement 61 | } 62 | ``` 63 | 64 | You can get the props via `entity.props`. 65 | 66 | ```ts 67 | const xEntity = new XEntity({ 68 | id: new XIdentifier("x"); 69 | }); 70 | console.log(xEntity.props.id); 71 | ``` 72 | 73 | 74 | **Example:** 75 | 76 | ```ts 77 | // Entity A 78 | class AIdentifier extends Identifier {} 79 | interface AProps { 80 | id: AIdentifier; 81 | } 82 | class AEntity extends Entity {} 83 | // Entity B 84 | class BIdentifier extends Identifier {} 85 | interface BProps { 86 | id: BIdentifier; 87 | } 88 | class BEntity extends Entity {} 89 | // A is not B 90 | const a = new AEntity({ 91 | id: new AIdentifier("1")) 92 | }); 93 | const b = new BEntity({ 94 | id: new BIdentifier("1") 95 | }); 96 | assert.ok(!a.equals(b), "A is not B"); 97 | ``` 98 | 99 | Props can includes other property. 100 | 101 | ```ts 102 | // Entity A 103 | class AIdentifier extends Identifier {} 104 | 105 | interface AProps { 106 | id: AIdentifier; 107 | a: number; 108 | b: string; 109 | } 110 | 111 | class AEntity extends Entity { 112 | constructor(props: AProps) { 113 | super(props); 114 | } 115 | } 116 | 117 | const entity = new AEntity({ 118 | id: new AIdentifier("a"), 119 | a: 42, 120 | b: "string" 121 | }); 122 | ``` 123 | 124 | ### ValueObject 125 | 126 | > Value object is an entity’s state, describing something about the entity or the things it owns. 127 | 128 | ValueObject's equability is values. 129 | 130 | ```ts 131 | import {ValueObject} from "ddd-base"; 132 | 133 | // X ValueObject 134 | type XProps = { value: number }; 135 | 136 | class XValue extends ValueObject { 137 | constructor(props: XProps) { 138 | super(props); 139 | } 140 | } 141 | // x1's value equal to x2's value 142 | const x1 = new XValue({ value: 42 }); 143 | const x2 = new XValue({ value: 42 }); 144 | console.log(x1.props.value); // => 42 145 | console.log(x2.props.value); // => 42 146 | console.log(x1.equals(x2));// => true 147 | // x3's value not equal both 148 | const x3 = new XValue({ value: 1 }); 149 | console.log(x1.equals(x3));// => false 150 | console.log(x2.equals(x3));// => false 151 | ``` 152 | 153 | :memo: ValueObject's props have not a limitation like Entity. 154 | Because, ValueObject's equability is not identifier. 155 | 156 | ### Repository 157 | 158 | > A repository is used to manage aggregate persistence and retrieval while ensuring that there is a separation between the domain model and the data model. 159 | 160 | `Repository` collect entity. 161 | 162 | Currently, `Repository` implementation is in-memory database like Map. 163 | This library provide following types of repository. 164 | 165 | - `NonNullableBaseRepository` 166 | - `NullableBaseRepository` 167 | 168 | #### NonNullableBaseRepository 169 | 170 | `NonNullableRepository` has initial value. 171 | In other words, NonNullableRepository#get always return a value. 172 | 173 | ```ts 174 | /** 175 | * NonNullableRepository has initial value. 176 | * In other words, NonNullableRepository#get always return a value. 177 | */ 178 | export declare class NonNullableRepository, Props extends Entity["props"], Id extends Props["id"]> { 179 | protected initialEntity: Entity; 180 | private core; 181 | constructor(initialEntity: Entity); 182 | readonly map: MapLike; 183 | readonly events: RepositoryEventEmitter; 184 | get(): Entity; 185 | getAll(): Entity[]; 186 | findById(entityId?: Id): Entity | undefined; 187 | save(entity: Entity): void; 188 | delete(entity: Entity): void; 189 | clear(): void; 190 | } 191 | 192 | ``` 193 | 194 | #### NullableBaseRepository 195 | 196 | `NullableRepository` has not initial value. 197 | In other word, NullableRepository#get may return undefined. 198 | 199 | ```ts 200 | /** 201 | * NullableRepository has not initial value. 202 | * In other word, NullableRepository#get may return undefined. 203 | */ 204 | export declare class NullableRepository, Props extends Entity["props"], Id extends Props["id"]> { 205 | private core; 206 | constructor(); 207 | readonly map: MapLike; 208 | readonly events: RepositoryEventEmitter; 209 | get(): Entity | undefined; 210 | getAll(): Entity[]; 211 | findById(entityId?: Id): Entity | undefined; 212 | save(entity: Entity): void; 213 | delete(entity: Entity): void; 214 | clear(): void; 215 | } 216 | ``` 217 | 218 | ### Converter 219 | 220 | > JSON <-> Props <-> Entity 221 | 222 | Converter is that convert JSON <-> Props <-> Entity. 223 | 224 | `createConverter` create `Converter` instance from `Props` and `JSON` types and converting definition. 225 | 226 | ```ts 227 | // Pass Props type and JSON types as generics 228 | // 1st argument is that a Constructor of entity that is required for creating entity from JSON 229 | // 2nd argument is that a mapping object 230 | // mapping object has tuple array for each property. 231 | // tuple is [Props to JSON, JSON to Props] 232 | createConverter(EntityConstructor, mappingObject): Converter; 233 | ``` 234 | 235 | `mappingObject` has tuple array for each property. 236 | 237 | ```ts 238 | const converter = createConverter(Entity, { 239 | id: [propsToJSON function, jsonToProps function], 240 | // [(prop value) => json value, (json value) => prop value] 241 | }); 242 | ``` 243 | 244 | Example of `createConveter`. 245 | 246 | ```ts 247 | 248 | // Entity A 249 | class AIdentifier extends Identifier { 250 | } 251 | 252 | interface AProps { 253 | id: AIdentifier; 254 | a1: number; 255 | a2: string; 256 | } 257 | 258 | class AEntity extends Entity { 259 | constructor(args: AProps) { 260 | super(args); 261 | } 262 | } 263 | 264 | interface AEntityJSON { 265 | id: string; 266 | a1: number; 267 | a2: string; 268 | } 269 | 270 | // Create converter 271 | // Tuple has two convert function that Props -> JSON and JSON -> Props 272 | const AConverter = createConverter(AEntity, { 273 | id: [prop => prop.toValue(), json => new AIdentifier(json)], 274 | a1: [prop => prop, json => json], 275 | a2: [prop => prop, json => json] 276 | }); 277 | const entity = new AEntity({ 278 | id: new AIdentifier("a"), 279 | a: 42, 280 | b: "b prop" 281 | }); 282 | // Entity to JSON 283 | const json = AConverter.toJSON(entity); 284 | assert.deepStrictEqual(json, { 285 | id: "a", 286 | a: 42, 287 | b: "b prop" 288 | }); 289 | // JSON to Entity 290 | const entity_conveterted = converter.fromJSON(json); 291 | assert.deepStrictEqual(entity, entity_conveterted); 292 | ``` 293 | 294 | :memo: Limitation: 295 | 296 | Convert can be possible one for one converting. 297 | 298 | ``` 299 | // Can not do convert following pattern 300 | // JSON -> Entity 301 | // a -> b, c properties 302 | ``` 303 | 304 | #### Nesting Converter 305 | 306 | You can set `Converter` instead of mapping functions. 307 | This pattern called **Nesting Converter**. 308 | 309 | ```ts 310 | // Parent has A and B 311 | class ParentIdentifier extends Identifier { 312 | } 313 | 314 | interface ParentJSON { 315 | id: string; 316 | a: AEntityJSON; 317 | b: BValueJSON; 318 | } 319 | 320 | interface ParentProps { 321 | id: ParentIdentifier; 322 | a: AEntity; 323 | b: BValue; 324 | } 325 | 326 | class ParentEntity extends Entity { 327 | } 328 | 329 | const ParentConverter = createConverter(ParentEntity, { 330 | id: [prop => prop.toValue(), json => new ParentIdentifier(json)], 331 | a: AConverter, // Set Conveter instead of mapping functions 332 | b: BConverter 333 | }); 334 | ``` 335 | 336 | For more details, see [test/Converter-test.ts](./test/Converter-test.ts). 337 | 338 | ### [Deprecated] Serializer 339 | 340 | > JSON <-> Entity 341 | 342 | DDD-base just define the interface of `Serializer` that does following converting. 343 | 344 | - Convert from JSON to Entity 345 | - Convert from Entity to JSON 346 | 347 | You can implement `Serializer` interface and use it. 348 | 349 | ```ts 350 | export interface Serializer { 351 | /** 352 | * Convert Entity to JSON format 353 | */ 354 | toJSON(entity: Entity): JSON; 355 | 356 | /** 357 | * Convert JSON to Entity 358 | */ 359 | fromJSON(json: JSON): Entity; 360 | } 361 | ``` 362 | 363 | 364 | Implementation: 365 | 366 | ```ts 367 | // Entity A 368 | class AIdentifier extends Identifier {} 369 | 370 | interface AEntityArgs { 371 | id: AIdentifier; 372 | a: number; 373 | b: string; 374 | } 375 | 376 | class AEntity extends Entity { 377 | private a: number; 378 | private b: string; 379 | 380 | constructor(args: AEntityArgs) { 381 | super(args.id); 382 | this.a = args.a; 383 | this.b = args.b; 384 | } 385 | 386 | toJSON(): AEntityJSON { 387 | return { 388 | id: this.id.toValue(), 389 | a: this.a, 390 | b: this.b 391 | }; 392 | } 393 | } 394 | // JSON 395 | interface AEntityJSON { 396 | id: string; 397 | a: number; 398 | b: string; 399 | } 400 | 401 | // Serializer 402 | const ASerializer: Serializer = { 403 | fromJSON(json) { 404 | return new AEntity({ 405 | id: new AIdentifier(json.id), 406 | a: json.a, 407 | b: json.b 408 | }); 409 | }, 410 | toJSON(entity) { 411 | return entity.toJSON(); 412 | } 413 | }; 414 | 415 | it("toJSON: Entity -> JSON", () => { 416 | const entity = new AEntity({ 417 | id: new AIdentifier("a"), 418 | a: 42, 419 | b: "b prop" 420 | }); 421 | const json = ASerializer.toJSON(entity); 422 | assert.deepStrictEqual(json, { 423 | id: "a", 424 | a: 42, 425 | b: "b prop" 426 | }); 427 | }); 428 | 429 | it("fromJSON: JSON -> Entity", () => { 430 | const entity = ASerializer.fromJSON({ 431 | id: "a", 432 | a: 42, 433 | b: "b prop" 434 | }); 435 | assert.ok(entity instanceof AEntity, "entity should be instanceof AEntity"); 436 | assert.deepStrictEqual( 437 | ASerializer.toJSON(entity), 438 | { 439 | id: "a", 440 | a: 42, 441 | b: "b prop" 442 | }, 443 | "JSON <-> Entity" 444 | ); 445 | }); 446 | ``` 447 | 448 | ## :memo: Design Note 449 | 450 | ### Why entity and value object has `props`? 451 | 452 | It come from TypeScript limitation. 453 | TypeScript can not define type of class's properties. 454 | 455 | ```ts 456 | // A limitation of generics interface 457 | type AEntityProps = { 458 | key: string; 459 | } 460 | class AEntity extends Entity {} 461 | 462 | const aEntity = new AEntity({ 463 | key: "value" 464 | }); 465 | // can not type 466 | aEntity.key; // type is any? 467 | ``` 468 | 469 | We can resolve this issue by introducing `props` property. 470 | 471 | ```ts 472 | // `props` make realize typing 473 | type AEntityProps = { 474 | key: string; 475 | } 476 | class AEntity extends Entity {} 477 | 478 | const aEntity = new AEntity({ 479 | key: "value" 480 | }); 481 | // can not type 482 | aEntity.props; // props is AEntityProps 483 | ``` 484 | 485 | This approach is similar with [React](https://reactjs.org/). 486 | 487 | - [Why did React use 'props' as an abbreviation for property/properties? - Quora](https://www.quora.com/Why-did-React-use-props-as-an-abbreviation-for-property-properties) 488 | 489 | ### Nesting props is ugly 490 | 491 | If you want to access nested propery via `props`, you have written `a.props.b.props.c`. 492 | It is ugly syntax. 493 | 494 | Instead of this, you can assign `props` values to entity's properties directly. 495 | 496 | ```ts 497 | class ShoppingCartItemIdentifier extends Identifier { 498 | } 499 | 500 | interface ShoppingCartItemProps { 501 | id: ShoppingCartItemIdentifier; 502 | name: string; 503 | price: number; 504 | } 505 | 506 | class ShoppingCartItem extends Entity implements ShoppingCartItemProps { 507 | id: ShoppingCartItemIdentifier; 508 | name: string; 509 | price: number; 510 | 511 | constructor(props: ShoppingCartItemProps) { 512 | // pass to props 513 | super(props); 514 | // assign own property 515 | this.id = props.id; 516 | this.name = props.name; 517 | this.price = props.price; 518 | } 519 | } 520 | 521 | const item = new ShoppingCartItem({ 522 | id: new ShoppingCartItemIdentifier("item 1"); 523 | name: "bag"; 524 | price: 1000 525 | }); 526 | 527 | console.log(item.props.price === item.price); // => true 528 | ``` 529 | 530 | ### `props` is readonly by default 531 | 532 | > This is related with "Nesting props is ugly" 533 | 534 | `props` is `readonly` and `Object.freeze(props)` by default. 535 | 536 | **props**: 537 | 538 | It is clear that `props` are a Entity's **configureation**. 539 | They are **received** from above and immutable as far as the Entity receiving them is concerned. 540 | 541 | **state**: 542 | 543 | ddd-base does not define `state` type. 544 | But, `state` is own properties of Entity. 545 | It is mutable value and it can be modified by default. 546 | 547 | For example, `this.id`, `this.name`, and `this.price` are state of `ShoppingCartItem`. 548 | You can modify this state. 549 | 550 | ```ts 551 | class ShoppingCartItemIdentifier extends Identifier { 552 | } 553 | 554 | interface ShoppingCartItemProps { 555 | id: ShoppingCartItemIdentifier; 556 | name: string; 557 | price: number; 558 | } 559 | 560 | class ShoppingCartItem extends Entity implements ShoppingCartItemProps { 561 | id: ShoppingCartItemIdentifier; 562 | name: string; 563 | price: number; 564 | 565 | constructor(props: ShoppingCartItemProps) { 566 | // pass to props 567 | super(props); 568 | // assign own property 569 | this.id = props.id; 570 | this.name = props.name; 571 | this.price = props.price; 572 | } 573 | } 574 | ``` 575 | 576 | ### Changing _props_ and _state_ 577 | 578 | | | *props* | *state* | 579 | | ----------------------------------------- | ------- | ------- | 580 | | Can get initial value from parent Entity? | Yes | Yes | 581 | | Can be changed by parent Entity? | Yes | No | 582 | | Can set default values inside Entity? | Yes | Yes | 583 | | Can change inside Entity? | No | Yes | 584 | | Can set initial value for child Entity? | Yes | Yes | 585 | 586 | 587 | 588 | Related concept: 589 | 590 | - [react-guide/props-vs-state.md at master · uberVU/react-guide](https://github.com/uberVU/react-guide/blob/master/props-vs-state.md) 591 | - [Thinking in React - React](https://reactjs.org/docs/thinking-in-react.html) 592 | 593 | ## Real UseCase 594 | 595 | - [azu/irodr: RSS reader client like LDR for Inoreader.](https://github.com/azu/irodr "azu/irodr: RSS reader client like LDR for Inoreader.") 596 | - [azu/searchive: Search All My Documents{PDF}.](https://github.com/azu/searchive "azu/searchive: Search All My Documents{PDF}.") 597 | - [proofdict/editor: Proofdict editor.](https://github.com/proofdict/editor "proofdict/editor: Proofdict editor.") 598 | 599 | ## Changelog 600 | 601 | See [Releases page](https://github.com/almin/ddd-base/releases). 602 | 603 | ## Running tests 604 | 605 | Install devDependencies and Run `npm test`: 606 | 607 | npm i -d && npm test 608 | 609 | ## Contributing 610 | 611 | Pull requests and stars are always welcome. 612 | 613 | For bugs and feature requests, [please create an issue](https://github.com/almin/ddd-base/issues). 614 | 615 | 1. Fork it! 616 | 2. Create your feature branch: `git checkout -b my-new-feature` 617 | 3. Commit your changes: `git commit -am 'Add some feature'` 618 | 4. Push to the branch: `git push origin my-new-feature` 619 | 5. Submit a pull request :D 620 | 621 | ## Author 622 | 623 | - [github/azu](https://github.com/azu) 624 | - [twitter/azu_re](https://twitter.com/azu_re) 625 | 626 | ## License 627 | 628 | MIT © azu 629 | --------------------------------------------------------------------------------