├── src ├── core │ ├── domain │ │ ├── common │ │ │ └── .gitkeep │ │ ├── exception │ │ │ └── .gitkeep │ │ ├── service │ │ │ └── localstorage.service.ts │ │ └── usecase │ │ │ └── usecase.ts │ ├── presentation │ │ ├── view-model.ts │ │ └── presenter.ts │ ├── common │ │ └── mapper.ts │ └── data │ │ └── service │ │ └── localstorage-browser.service.ts ├── features │ └── todo │ │ ├── domain │ │ ├── common │ │ │ └── .gitkeep │ │ ├── exception │ │ │ └── .gitkeep │ │ ├── entity │ │ │ ├── todo.entity.ts │ │ │ └── todo.entity.spec.ts │ │ ├── usecase │ │ │ ├── get-active-todos-count.usecase.ts │ │ │ ├── get-all-todos.usecase.ts │ │ │ ├── get-active-todos.usecase.ts │ │ │ ├── get-completed-todos.usecase.ts │ │ │ ├── remove-completed-todos.usecas.ts │ │ │ ├── mark-all-todos-as-active.usecase.ts │ │ │ ├── mark-all-todos-as-completed.usecase.ts │ │ │ ├── add-todo.usecase.ts │ │ │ ├── remove-todo-id.usecase.ts │ │ │ ├── get-todo-by-id.usecase.ts │ │ │ ├── mark-todo-as-active.usecase.ts │ │ │ ├── mark-todo-as-completed.usecase.ts │ │ │ ├── filter-todos.usecase.ts │ │ │ └── get-all-todos.usecase.spec.ts │ │ └── repository │ │ │ └── todo.repository.ts │ │ ├── presentation │ │ ├── common │ │ │ └── .gitkeep │ │ ├── viewmodel │ │ │ └── todos.viewmodel.ts │ │ ├── mapper │ │ │ └── todo.mapper.ts │ │ └── presenter │ │ │ ├── todo.presenter.ts │ │ │ ├── todo-default.presenter.spec.ts │ │ │ └── todo-default.presenter.ts │ │ └── data │ │ └── repository │ │ ├── localstorage │ │ ├── dto │ │ │ └── .gitkeep │ │ ├── mapper │ │ │ └── .gitkeep │ │ └── todo.localstorage.repository.ts │ │ └── inmemory │ │ ├── dto │ │ └── todo-mock.dto.ts │ │ ├── mapper │ │ └── todo-mock.mapper.ts │ │ ├── todo.inmemory.repository.spec.ts │ │ └── todo.inmemory.repository.ts ├── index.ts └── app │ └── main.app.ts ├── .gitignore ├── .prettierrc ├── jestconfig.json ├── tslint.json ├── tsconfig.json ├── index.ts ├── README.md ├── package.json └── TODO.md /src/core/domain/common/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/domain/exception/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/features/todo/domain/common/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/features/todo/domain/exception/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/features/todo/presentation/common/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | lib/ 4 | .vscode/ -------------------------------------------------------------------------------- /src/features/todo/data/repository/localstorage/dto/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/features/todo/data/repository/localstorage/mapper/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/core/presentation/view-model.ts: -------------------------------------------------------------------------------- 1 | export class ViewModel {} 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } -------------------------------------------------------------------------------- /src/core/presentation/presenter.ts: -------------------------------------------------------------------------------- 1 | export abstract class Presenter { 2 | onDestroy() {} 3 | } 4 | -------------------------------------------------------------------------------- /src/core/common/mapper.ts: -------------------------------------------------------------------------------- 1 | export interface Mapper { 2 | mapFrom(input: I): O; 3 | mapTo(input: O): I; 4 | } 5 | -------------------------------------------------------------------------------- /src/core/domain/service/localstorage.service.ts: -------------------------------------------------------------------------------- 1 | export abstract class LocalStorageService { 2 | abstract getItem(key: string): any; 3 | abstract setItem(key: string, value: any): void; 4 | } 5 | -------------------------------------------------------------------------------- /src/core/domain/usecase/usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | export interface UseCase { 4 | execute(request?: Request): Observable | Response; 5 | } 6 | -------------------------------------------------------------------------------- /jestconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | "^.+\\.(t|j)sx?$": "ts-jest" 4 | }, 5 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 6 | "testPathIgnorePatterns": ["/lib/"], 7 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"] 8 | } -------------------------------------------------------------------------------- /src/features/todo/data/repository/inmemory/dto/todo-mock.dto.ts: -------------------------------------------------------------------------------- 1 | export class TodoMockDto { 2 | id: string; 3 | title: string; // <-- different as in todo domain entity 4 | completed: boolean; 5 | 6 | constructor(params: TodoMockDto) { 7 | Object.assign(this, params); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/features/todo/domain/entity/todo.entity.ts: -------------------------------------------------------------------------------- 1 | export class TodoEntity { 2 | id: string; 3 | name: string; 4 | completed?: boolean; 5 | 6 | private constructor(params: TodoEntity) { 7 | Object.assign(this, params); 8 | } 9 | 10 | static create(params: TodoEntity) { 11 | return new this(params); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-prettier"], 3 | "rules": { 4 | "quotemark": [true, "single"], 5 | "no-console": false, 6 | "member-access": false, 7 | "object-literal-sort-keys": false, 8 | "interface-name": false, 9 | "member-ordering": false, 10 | "no-empty": false 11 | } 12 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib/", 4 | "sourceMap": true, 5 | "declaration": true, 6 | "noImplicitAny": false, 7 | "module": "commonjs", 8 | "target": "es5", 9 | "jsx": "react", 10 | "allowJs": true, 11 | }, 12 | "include": ["src"], 13 | "exclude": ["node_modules", "**/*.spec.ts"] 14 | } -------------------------------------------------------------------------------- /src/features/todo/presentation/viewmodel/todos.viewmodel.ts: -------------------------------------------------------------------------------- 1 | export class TodoStateVM { 2 | filter: 'active' | 'completed' | 'all' = 'all'; 3 | todos: TodoVM[] = []; 4 | activeTodosCount: number = 0; 5 | } 6 | 7 | export interface TodoVM { 8 | id: string; 9 | name: string; 10 | completed: boolean; 11 | editing?: boolean; // <-- different as in todo domain entity 12 | } 13 | -------------------------------------------------------------------------------- /src/features/todo/domain/entity/todo.entity.spec.ts: -------------------------------------------------------------------------------- 1 | import { TodoEntity } from './todo.entity'; 2 | 3 | describe('Todo Entity', () => { 4 | it('should be properly initialized', () => { 5 | const model = TodoEntity.create({ 6 | id: '1', 7 | name: 'foo', 8 | completed: true, 9 | }); 10 | 11 | expect(model.id).toEqual('1'); 12 | expect(model.name).toEqual('foo'); 13 | expect(model.completed).toEqual(true); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './features/todo/data/repository/inmemory/todo.inmemory.repository'; 2 | export * from './features/todo/data/repository/localstorage/todo.localstorage.repository'; 3 | export * from './features/todo/domain/repository/todo.repository'; 4 | export * from './features/todo/presentation/presenter/todo-default.presenter'; 5 | export * from './features/todo/presentation/presenter/todo.presenter'; 6 | export * from './features/todo/presentation/viewmodel/todos.viewmodel'; 7 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './src/features/todo/data/repository/inmemory/todo.inmemory.repository'; 2 | export * from './src/features/todo/data/repository/localstorage/todo.localstorage.repository'; 3 | export * from './src/features/todo/presentation/viewmodel/todos.viewmodel'; 4 | export * from './src/features/todo/domain/repository/todo.repository'; 5 | export * from './src/features/todo/presentation/presenter/todo.presenter'; 6 | export * from './src/features/todo/presentation/presenter/todo-default.presenter'; -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/get-active-todos-count.usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoRepository } from '../repository/todo.repository'; 4 | 5 | export class GetActiveTodosCountUseCase implements UseCase { 6 | constructor(private todoRepository: TodoRepository) {} 7 | 8 | execute(): Observable { 9 | return this.todoRepository.getActiveTodosCount(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/get-all-todos.usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoEntity } from '../entity/todo.entity'; 4 | import { TodoRepository } from '../repository/todo.repository'; 5 | 6 | export class GetAllTodosUseCase implements UseCase { 7 | constructor(private todoRepository: TodoRepository) {} 8 | 9 | execute(): Observable { 10 | return this.todoRepository.getAllTodos(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/get-active-todos.usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoEntity } from '../entity/todo.entity'; 4 | import { TodoRepository } from '../repository/todo.repository'; 5 | 6 | export class GetActiveTodosUseCase implements UseCase { 7 | constructor(private todoRepository: TodoRepository) {} 8 | 9 | execute(): Observable { 10 | return this.todoRepository.getActiveTodos(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/get-completed-todos.usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoEntity } from '../entity/todo.entity'; 4 | import { TodoRepository } from '../repository/todo.repository'; 5 | 6 | export class GetCompletedTodosUseCase implements UseCase { 7 | constructor(private todoRepository: TodoRepository) {} 8 | 9 | execute(): Observable { 10 | return this.todoRepository.getCompletedTodos(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/remove-completed-todos.usecas.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoEntity } from '../entity/todo.entity'; 4 | import { TodoRepository } from '../repository/todo.repository'; 5 | 6 | export class RemoveCompletedTodosUseCase implements UseCase { 7 | constructor(private todoRepository: TodoRepository) {} 8 | 9 | execute(): Observable { 10 | return this.todoRepository.removeCompletedTodos(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/mark-all-todos-as-active.usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoEntity } from '../entity/todo.entity'; 4 | import { TodoRepository } from '../repository/todo.repository'; 5 | 6 | export class MarkAllTodosAsActiveUseCase implements UseCase { 7 | constructor(private todoRepository: TodoRepository) {} 8 | 9 | execute(): Observable { 10 | return this.todoRepository.markAllTodosAsActive(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/mark-all-todos-as-completed.usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoEntity } from '../entity/todo.entity'; 4 | import { TodoRepository } from '../repository/todo.repository'; 5 | 6 | export class MarkAllTodosAsCompletedUseCase implements UseCase { 7 | constructor(private todoRepository: TodoRepository) {} 8 | 9 | execute(): Observable { 10 | return this.todoRepository.markAllTodosAsCompleted(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/features/todo/presentation/mapper/todo.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Mapper } from '../../../../core/common/mapper'; 2 | import { TodoEntity } from '../../domain/entity/todo.entity'; 3 | import { TodoVM } from '../viewmodel/todos.viewmodel'; 4 | 5 | export class TodoViewModelMapper implements Mapper { 6 | mapFrom(input: TodoEntity): TodoVM { 7 | return { id: input.id, name: input.name, completed: input.completed }; 8 | } 9 | 10 | mapTo(input: TodoVM): TodoEntity { 11 | return { id: input.id, name: input.name, completed: input.completed }; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/add-todo.usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoEntity } from '../entity/todo.entity'; 4 | import { TodoRepository } from '../repository/todo.repository'; 5 | 6 | export interface AddTodoUseCaseDTO { 7 | name: string; 8 | } 9 | 10 | export class AddTodoUseCase implements UseCase { 11 | constructor(private todoRepository: TodoRepository) {} 12 | 13 | execute(request: AddTodoUseCaseDTO): Observable { 14 | return this.todoRepository.addTodo(request.name); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/remove-todo-id.usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoEntity } from '../entity/todo.entity'; 4 | import { TodoRepository } from '../repository/todo.repository'; 5 | 6 | export interface RemoveTodoUseCaseDto { 7 | id: string; 8 | } 9 | 10 | export class RemoveTodoUseCase implements UseCase { 11 | constructor(private todoRepository: TodoRepository) {} 12 | 13 | execute(request: RemoveTodoUseCaseDto): Observable { 14 | return this.todoRepository.removeTodo(request.id); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/get-todo-by-id.usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoEntity } from '../entity/todo.entity'; 4 | import { TodoRepository } from '../repository/todo.repository'; 5 | 6 | export interface GetTodoByIdUseCaseDto { 7 | id: string; 8 | } 9 | 10 | export class GetTodoByIdUseCase implements UseCase { 11 | constructor(private todoRepository: TodoRepository) {} 12 | 13 | execute(request: GetTodoByIdUseCaseDto): Observable { 14 | return this.todoRepository.getTodoById(request.id); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/mark-todo-as-active.usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoEntity } from '../entity/todo.entity'; 4 | import { TodoRepository } from '../repository/todo.repository'; 5 | 6 | export interface MarkTodoAsActiveUseCaseDto { 7 | id: string; 8 | } 9 | 10 | export class MarkTodoAsActiveUseCase implements UseCase { 11 | constructor(private todoRepository: TodoRepository) {} 12 | 13 | execute(request: MarkTodoAsActiveUseCaseDto): Observable { 14 | return this.todoRepository.markTodoAsCompleted(request.id, false); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/mark-todo-as-completed.usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoEntity } from '../entity/todo.entity'; 4 | import { TodoRepository } from '../repository/todo.repository'; 5 | 6 | export interface MarkTodoAsCompletedUseCaseDto { 7 | id: string; 8 | } 9 | 10 | export class MarkTodoAsCompletedUseCase implements UseCase { 11 | constructor(private todoRepository: TodoRepository) {} 12 | 13 | execute(request: MarkTodoAsCompletedUseCaseDto): Observable { 14 | return this.todoRepository.markTodoAsCompleted(request.id, true); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/features/todo/data/repository/inmemory/mapper/todo-mock.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Mapper } from '../../../../../../core/common/mapper'; 2 | import { TodoEntity } from '../../../../domain/entity/todo.entity'; 3 | import { TodoMockDto } from '../dto/todo-mock.dto'; 4 | 5 | export class TodoMockMapper implements Mapper { 6 | mapFrom(input: TodoEntity): TodoMockDto { 7 | return { 8 | id: input.id, 9 | title: input.name, 10 | completed: input.completed 11 | }; 12 | } 13 | 14 | mapTo(input: TodoMockDto): TodoEntity { 15 | const todo = TodoEntity.create({ 16 | id: input.id, 17 | name: input.title, 18 | completed: input.completed 19 | }); 20 | 21 | return todo; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/features/todo/presentation/presenter/todo.presenter.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { TodoVM } from '../viewmodel/todos.viewmodel'; 3 | 4 | export abstract class TodoPresenter { 5 | abstract todos$: Observable; 6 | abstract activeTodosCount$: Observable; 7 | abstract filter$: Observable; 8 | 9 | abstract getAllTodos(): Observable; 10 | abstract getCompletedTodos(): void; 11 | abstract getActiveTodos(): void; 12 | abstract addTodo(name: string): Observable; 13 | abstract markTodoAsCompleted(id: string): void; 14 | abstract markTodoAsActive(id: string): void; 15 | abstract markAllTodosAsCompleted(): void; 16 | abstract markAllTodosAsActive(): void; 17 | abstract removeTodo(id: string): void; 18 | abstract removeCompletedTodos(): void; 19 | } 20 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/filter-todos.usecase.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { UseCase } from '../../../../core/domain/usecase/usecase'; 3 | import { TodoEntity } from '../entity/todo.entity'; 4 | import { TodoRepository } from '../repository/todo.repository'; 5 | 6 | export type FilterType = 'active' | 'completed' | 'all' | null; 7 | 8 | export interface FilterTodosUseCaseDTO { 9 | filter: FilterType; 10 | } 11 | export class FilterTodosUseCase implements UseCase { 12 | constructor(private todoRepository: TodoRepository) {} 13 | 14 | execute(request: FilterTodosUseCaseDTO): Observable { 15 | if (request.filter === 'active') { 16 | return this.todoRepository.getActiveTodos(); 17 | } else if (request.filter === 'completed') { 18 | return this.todoRepository.getCompletedTodos(); 19 | } else { 20 | return this.todoRepository.getAllTodos(); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/features/todo/domain/repository/todo.repository.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { TodoEntity } from '../entity/todo.entity'; 3 | 4 | export abstract class TodoRepository { 5 | public abstract getAllTodos(): Observable; 6 | public abstract getCompletedTodos(): Observable; 7 | public abstract getActiveTodos(): Observable; 8 | public abstract getActiveTodosCount(): Observable; 9 | public abstract addTodo(name: string): Observable; 10 | public abstract removeTodo(id: string): Observable; 11 | public abstract removeCompletedTodos(): Observable; 12 | public abstract getTodoById(id: string): Observable; 13 | public abstract markTodoAsCompleted(id: string, isCompleted: boolean): Observable; 14 | public abstract markAllTodosAsCompleted(): Observable; 15 | public abstract markAllTodosAsActive(): Observable; 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Todo Clean Architecture Core 2 | 3 | Project aiming on implementing Uncle Bob Clean Architecture in javascript 4 | 5 | This is the core business logic shared across all possible UI implementations 6 | 7 | * Simple terminal - this repo 8 | * VanillaJS - https://github.com/pnowak2/todo-clean-architecture-vanillajs 9 | * Angular - https://github.com/pnowak2/todo-clean-architecture-angular 10 | * React - https://github.com/pnowak2/todo-clean-architecture-react.git 11 | * Vue - https://github.com/pnowak2/todo-clean-architecture-vue.git 12 | * Ionic - https://github.com/pnowak2/todo-clean-architecture-ionic.git 13 | * Electron - https://github.com/pnowak2/todo-clean-architecture-electron.git 14 | * CLI - https://github.com/pnowak2/todo-clean-architecture-cli.git 15 | 16 | ## Run the demo 17 | 18 | Run `npm install` or `yarn` in root folder to install dependencies, then perform `npm run start` to run the demo. 19 | 20 | ## Build 21 | 22 | Run `npm run build` to build the project. 23 | 24 | ## Running unit tests 25 | 26 | Run `npm run test` to execute the unit tests. 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@domisoft/todo-clean-architecture", 3 | "version": "1.0.11", 4 | "description": "todo project following uncle bob clean architecture patterns", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "files": [ 8 | "lib/**/*" 9 | ], 10 | "scripts": { 11 | "test": "jest --config jestconfig.json", 12 | "test:watch": "jest --watchAll --config jestconfig.json", 13 | "build": "tsc", 14 | "format": "prettier --write \"src/**/*.ts\" \"src/**/*.js\"", 15 | "lint": "tslint -p tsconfig.json", 16 | "prepare": "npm run build", 17 | "prepublishOnly": "npm test && npm run lint", 18 | "preversion": "npm run lint", 19 | "version": "npm run format && git add -A src", 20 | "postversion": "git push && git push --tags", 21 | "start": "npm run build && node lib/app/main.app" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/pnowak2/todo-clean-architecture" 26 | }, 27 | "author": "Piotr Nowak", 28 | "license": "ISC", 29 | "keywords": [ 30 | "Clean", 31 | "Architecture" 32 | ], 33 | "dependencies": { 34 | "rxjs": "^6.5.3" 35 | }, 36 | "devDependencies": { 37 | "@types/jest": "^24.0.23", 38 | "@types/node": "^12.12.16", 39 | "jest": "^24.9.0", 40 | "prettier": "^1.19.1", 41 | "ts-jest": "^24.2.0", 42 | "ts-loader": "^6.2.1", 43 | "tslint": "^5.20.1", 44 | "tslint-config-prettier": "^1.18.0", 45 | "typescript": "^3.7.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/core/data/service/localstorage-browser.service.ts: -------------------------------------------------------------------------------- 1 | import { LocalStorageService } from '../../domain/service/localstorage.service'; 2 | 3 | interface Cache { 4 | [key: string]: any; 5 | } 6 | 7 | export class LocalStorageBrowserService implements LocalStorageService { 8 | private cache: Cache; 9 | 10 | constructor(private localStorage: Storage, private keyPrefix: string = 'todo-app') { 11 | this.cache = {}; 12 | 13 | window.addEventListener('storage', this.handleStorageEvent.bind(this)); 14 | } 15 | 16 | public getItem(key: string): any { 17 | const normalizedKey = this.normalizeKey(key); 18 | 19 | if (normalizedKey in this.cache) { 20 | return this.cache[normalizedKey]; 21 | } 22 | 23 | const value = JSON.parse(this.localStorage.getItem(normalizedKey)); 24 | this.cache[normalizedKey] = value; 25 | 26 | return value; 27 | } 28 | 29 | public setItem(key: string, value: any): void { 30 | const normalizedKey = this.normalizeKey(key); 31 | this.cache[normalizedKey] = value; 32 | const stringifiedValue = JSON.stringify(value); 33 | 34 | this.localStorage.setItem(normalizedKey, stringifiedValue); 35 | } 36 | 37 | private handleStorageEvent(event: StorageEvent) { 38 | if (!event.key.startsWith(this.keyPrefix)) { 39 | return; 40 | } 41 | 42 | if (event.newValue === null) { 43 | delete this.cache[event.key]; 44 | } else { 45 | this.cache[event.key] = JSON.parse(event.newValue); 46 | } 47 | } 48 | 49 | private normalizeKey(key: string): string { 50 | return `${this.keyPrefix}-${key}`; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/features/todo/data/repository/inmemory/todo.inmemory.repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { TodoInMemoryRepository } from '../../../../../features/todo/data/repository/inmemory/todo.inmemory.repository'; 2 | import { TodoRepository } from '../../../../../features/todo/domain/repository/todo.repository'; 3 | import { forkJoin } from 'rxjs'; 4 | import { switchMap } from 'rxjs/operators'; 5 | 6 | describe('Todo In Memory Repository', () => { 7 | let repo: TodoRepository; 8 | 9 | beforeEach(() => { 10 | repo = new TodoInMemoryRepository([ 11 | { 12 | id: '1', 13 | title: 'one', 14 | completed: false 15 | }, 16 | { 17 | id: '2', 18 | title: 'two', 19 | completed: false 20 | }, 21 | { 22 | id: '3', 23 | title: 'three', 24 | completed: true 25 | }, 26 | ]); 27 | }); 28 | 29 | it('get all', (done) => { 30 | repo.getAllTodos().subscribe(todos => { 31 | expect(todos.length).toEqual(3); 32 | done(); 33 | }) 34 | }); 35 | 36 | it('get completed', (done) => { 37 | repo.getCompletedTodos().subscribe(todos => { 38 | expect(todos.length).toEqual(1); 39 | done(); 40 | }) 41 | }); 42 | 43 | it('get active', (done) => { 44 | repo.getActiveTodos().subscribe(todos => { 45 | expect(todos.length).toEqual(2); 46 | done(); 47 | }) 48 | }); 49 | 50 | it('add todo', (done) => { 51 | const name = 'bar'; 52 | const add$ = repo.addTodo(name); 53 | const all$ = repo.getAllTodos(); 54 | 55 | forkJoin(add$, all$).subscribe(([todo, todos]) => { 56 | expect(todo.name).toEqual('bar'); 57 | expect(todos.length).toEqual(4); 58 | done(); 59 | }) 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/app/main.app.ts: -------------------------------------------------------------------------------- 1 | import { of } from 'rxjs'; 2 | import { catchError } from 'rxjs/operators'; 3 | import { TodoMockDto } from '../features/todo/data/repository/inmemory/dto/todo-mock.dto'; 4 | import { TodoInMemoryRepository } from '../features/todo/data/repository/inmemory/todo.inmemory.repository'; 5 | import { TodoRepository } from '../features/todo/domain/repository/todo.repository'; 6 | import { TodoDefaultPresenter } from '../features/todo/presentation/presenter/todo-default.presenter'; 7 | import { TodoPresenter } from '../features/todo/presentation/presenter/todo.presenter'; 8 | 9 | export class TerminalApp { 10 | private todoApp: TodoPresenter = new TodoDefaultPresenter(this.todoRepository); 11 | 12 | constructor(private todoRepository: TodoRepository) { 13 | this.todoApp.todos$.subscribe(todos => { 14 | console.log('todos', todos); 15 | }); 16 | 17 | this.todoApp.activeTodosCount$.subscribe(todosCount => { 18 | console.log('active todos count', todosCount); 19 | }); 20 | 21 | this.todoApp.filter$.subscribe(filter => { 22 | console.log('filter', filter); 23 | }); 24 | } 25 | 26 | public run() { 27 | this.todoApp.getAllTodos().subscribe(todos => { 28 | console.log('--- listing todos length ---', todos.length) 29 | }); 30 | this.todoApp.addTodo('new todo').pipe( 31 | catchError(err => { 32 | console.log('--- error occured ---', err); 33 | return of([]); 34 | }) 35 | ).subscribe(todo => { 36 | console.log('--- added todo ---', todo); 37 | }) 38 | } 39 | } 40 | 41 | const db = [ 42 | new TodoMockDto({ id: '1', title: 'todo 1', completed: true }), 43 | new TodoMockDto({ id: '2', title: 'todo 2', completed: false }), 44 | new TodoMockDto({ id: '3', title: 'todo 3', completed: false }), 45 | ]; 46 | 47 | new TerminalApp(new TodoInMemoryRepository(db)).run(); 48 | -------------------------------------------------------------------------------- /src/features/todo/domain/usecase/get-all-todos.usecase.spec.ts: -------------------------------------------------------------------------------- 1 | import { of } from 'rxjs'; 2 | import { TodoEntity } from '../entity/todo.entity'; 3 | import { TodoRepository } from '../repository/todo.repository'; 4 | import { GetAllTodosUseCase } from './get-all-todos.usecase'; 5 | import { GetCompletedTodosUseCase } from './get-completed-todos.usecase'; 6 | 7 | describe('Get All Todos Use Case', () => { 8 | 9 | // const repo: TodoRepository = { 10 | // getAllTodos: jest.fn().mockReturnValueOnce(of([])) as any, 11 | // getCompletedTodos: jest.fn() as any, 12 | // } as TodoRepository; 13 | 14 | const repo: TodoRepository = { 15 | getAllTodos() { 16 | return of([ 17 | TodoEntity.create({ 18 | id: '1', 19 | name: 'one', 20 | completed: false 21 | }), 22 | TodoEntity.create({ 23 | id: '2', 24 | name: 'two', 25 | completed: false 26 | }), 27 | TodoEntity.create({ 28 | id: '3', 29 | name: 'three', 30 | completed: true 31 | }), 32 | ]); 33 | }, 34 | getCompletedTodos() { 35 | return of([ 36 | TodoEntity.create({ 37 | id: '3', 38 | name: 'three', 39 | completed: true 40 | }), 41 | ]); 42 | } 43 | } as TodoRepository; 44 | 45 | // it('get all', (done) => { 46 | // const getAllTodosUC = new GetAllTodosUseCase(repo); 47 | 48 | // getAllTodosUC.execute().subscribe(todos => { 49 | // expect(repo.getAllTodos).toHaveBeenCalled(); 50 | // expect(todos).toEqual([]); 51 | // done(); 52 | // }); 53 | // }); 54 | 55 | it('get all', (done) => { 56 | const getAllTodosUC = new GetAllTodosUseCase(repo); 57 | 58 | getAllTodosUC.execute().subscribe(todos => { 59 | expect(todos).toHaveLength(3); 60 | done(); 61 | }) 62 | }); 63 | 64 | it('get completed', (done) => { 65 | const getCompletedTodosUC = new GetCompletedTodosUseCase(repo); 66 | 67 | getCompletedTodosUC.execute().subscribe(todos => { 68 | expect(todos).toHaveLength(1); 69 | done(); 70 | }) 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/features/todo/data/repository/localstorage/todo.localstorage.repository.ts: -------------------------------------------------------------------------------- 1 | import { Observable, of } from 'rxjs'; 2 | import { map } from 'rxjs/operators'; 3 | import { LocalStorageService } from '../../../../../core/domain/service/localstorage.service'; 4 | import { TodoEntity } from '../../../domain/entity/todo.entity'; 5 | import { TodoRepository } from '../../../domain/repository/todo.repository'; 6 | 7 | export class TodoLocalStorageRepository implements TodoRepository { 8 | constructor(private localStorageService: LocalStorageService) {} 9 | 10 | public getAllTodos(): Observable { 11 | return of(this.localStorageService.getItem('todos')); 12 | } 13 | 14 | public getCompletedTodos(): Observable { 15 | throw Error('not implemented'); 16 | } 17 | 18 | public getActiveTodos(): Observable { 19 | throw Error('not implemented'); 20 | } 21 | 22 | public getActiveTodosCount(): Observable { 23 | return this.getActiveTodos().pipe(map(todos => todos.length)); 24 | } 25 | 26 | public addTodo(name: string): Observable { 27 | const todos: TodoEntity[] = this.localStorageService.getItem('todos') || []; 28 | const todo = TodoEntity.create({ id: Math.random().toString(), name }); 29 | 30 | this.localStorageService.setItem('todos', [...todos, todo]); 31 | 32 | return of(todo); 33 | } 34 | 35 | public getTodoById(id: string): Observable { 36 | const todos: TodoEntity[] = this.localStorageService.getItem('todos'); 37 | return of(todos.find(todo => todo.id === id)); 38 | } 39 | 40 | public removeTodo(id: string): Observable { 41 | throw Error('not implemented'); 42 | } 43 | 44 | public removeCompletedTodos(): Observable { 45 | throw Error('not implemented'); 46 | } 47 | 48 | public markTodoAsCompleted(id: string, isCompleted: boolean): Observable { 49 | throw Error('not implemented'); 50 | } 51 | 52 | public markAllTodosAsCompleted(): Observable { 53 | throw Error('not implemented'); 54 | } 55 | 56 | public markAllTodosAsActive(): Observable { 57 | throw Error('not implemented'); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/features/todo/data/repository/inmemory/todo.inmemory.repository.ts: -------------------------------------------------------------------------------- 1 | import { Observable, of } from 'rxjs'; 2 | import { map } from 'rxjs/operators'; 3 | import { TodoEntity } from '../../../domain/entity/todo.entity'; 4 | import { TodoRepository } from '../../../domain/repository/todo.repository'; 5 | import { TodoMockDto } from './dto/todo-mock.dto'; 6 | import { TodoMockMapper } from './mapper/todo-mock.mapper'; 7 | 8 | export class TodoInMemoryRepository implements TodoRepository { 9 | constructor(private data: TodoMockDto[] = []) { } 10 | 11 | private mapper = new TodoMockMapper(); 12 | 13 | public getAllTodos(): Observable { 14 | return of(this.data) 15 | .pipe( 16 | map(mocks => mocks.map(this.mapper.mapTo)) 17 | ); 18 | } 19 | 20 | public getCompletedTodos(): Observable { 21 | return of(this.data.filter(todo => todo.completed)) 22 | .pipe( 23 | map(mocks => mocks.map(this.mapper.mapTo)) 24 | ); 25 | } 26 | 27 | public getActiveTodos(): Observable { 28 | return of(this.data.filter(todo => !todo.completed)) 29 | .pipe( 30 | map(mocks => mocks.map(this.mapper.mapTo)) 31 | ); 32 | } 33 | 34 | public getActiveTodosCount(): Observable { 35 | return this.getActiveTodos().pipe( 36 | map(todos => todos.length) 37 | ); 38 | } 39 | 40 | public addTodo(name: string): Observable { 41 | const id = 'item-' + new Date().getTime(); 42 | const todo: TodoEntity = TodoEntity.create({ id, name }); 43 | 44 | this.data.push(this.mapper.mapFrom(todo)); 45 | return of(todo); 46 | } 47 | 48 | public getTodoById(id: string): Observable { 49 | return of(this.data.find(todo => todo.id === id)) 50 | .pipe( 51 | map(this.mapper.mapTo) 52 | ) 53 | } 54 | 55 | public removeTodo(id: string): Observable { 56 | const idx = this.data.findIndex(t => t.id === id); 57 | const todo = this.data.find(t => t.id === id); 58 | 59 | this.data.splice(idx, 1); 60 | 61 | return of(todo) 62 | .pipe( 63 | map(this.mapper.mapTo) 64 | ); 65 | } 66 | 67 | public removeCompletedTodos(): Observable { 68 | const activeTodos = this.data.filter(todo => !todo.completed); 69 | this.data = [...activeTodos]; 70 | 71 | return of(activeTodos) 72 | .pipe( 73 | map(mocks => mocks.map(this.mapper.mapTo)) 74 | ); 75 | } 76 | 77 | public markTodoAsCompleted(id: string, isCompleted: boolean): Observable { 78 | const todo = this.data.find(t => t.id === id); 79 | todo.completed = isCompleted; 80 | 81 | return of(todo) 82 | .pipe( 83 | map(this.mapper.mapTo) 84 | ); 85 | } 86 | 87 | public markAllTodosAsCompleted(): Observable { 88 | this.data = this.data.map(todo => ({ ...todo, completed: true })); 89 | 90 | return of(this.data) 91 | .pipe( 92 | map(mocks => mocks.map(this.mapper.mapTo)) 93 | ); 94 | } 95 | 96 | public markAllTodosAsActive(): Observable { 97 | this.data = this.data.map(todo => ({ ...todo, completed: false })); 98 | 99 | return of(this.data) 100 | .pipe( 101 | map(mocks => mocks.map(this.mapper.mapTo)) 102 | ); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/features/todo/presentation/presenter/todo-default.presenter.spec.ts: -------------------------------------------------------------------------------- 1 | import { skip } from 'rxjs/operators'; 2 | import { TodoMockDto } from '../../data/repository/inmemory/dto/todo-mock.dto'; 3 | import { TodoInMemoryRepository } from './../../data/repository/inmemory/todo.inmemory.repository'; 4 | import { TodoDefaultPresenter } from './todo-default.presenter'; 5 | import { TodoPresenter } from './todo.presenter'; 6 | 7 | describe('Todo Presenter', () => { 8 | let todoPresenter: TodoPresenter; 9 | 10 | const item1 = { id: '1', title: 'todo 1', completed: true }; 11 | const item2 = { id: '2', title: 'todo 2', completed: false }; 12 | const db: TodoMockDto[] = [item1, item2]; 13 | 14 | beforeEach(() => { 15 | todoPresenter = new TodoDefaultPresenter(new TodoInMemoryRepository(db)); 16 | }); 17 | 18 | describe('Initial State', () => { 19 | describe('Todos', () => { 20 | it('should return empty array of todos', done => { 21 | todoPresenter.todos$.subscribe(todos => { 22 | expect(todos).toEqual([]); 23 | done(); 24 | }); 25 | }); 26 | }); 27 | 28 | describe('Active Todos Count', () => { 29 | it('should return zero', done => { 30 | todoPresenter.activeTodosCount$.subscribe(count => { 31 | expect(count).toEqual(0); 32 | done(); 33 | }); 34 | }); 35 | }); 36 | 37 | describe('Filter', () => { 38 | it('should return "all"', done => { 39 | todoPresenter.filter$.subscribe(filter => { 40 | expect(filter).toEqual('all'); 41 | done(); 42 | }); 43 | }); 44 | }); 45 | }); 46 | 47 | describe('Get All Todos', () => { 48 | describe('Todos', () => { 49 | it('should return proper todos from repository', done => { 50 | todoPresenter.todos$.pipe(skip(1)).subscribe(todos => { 51 | expect(todos).toHaveLength(2); 52 | 53 | expect(todos[0].id).toEqual(item1.id); 54 | expect(todos[0].name).toEqual(item1.title); 55 | expect(todos[0].completed).toEqual(item1.completed); 56 | expect(todos[0].editing).toBeFalsy(); 57 | 58 | expect(todos[1].id).toEqual(item2.id); 59 | expect(todos[1].name).toEqual(item2.title); 60 | expect(todos[1].completed).toEqual(item2.completed); 61 | expect(todos[1].editing).toBeFalsy(); 62 | 63 | done(); 64 | }); 65 | 66 | todoPresenter.getAllTodos(); 67 | }); 68 | }); 69 | 70 | describe('Active Todos Count', () => { 71 | it('should return proper counts of todos', done => { 72 | todoPresenter.activeTodosCount$.pipe(skip(1)).subscribe(count => { 73 | expect(count).toEqual(1); 74 | done(); 75 | }); 76 | 77 | todoPresenter.getAllTodos(); 78 | }); 79 | }); 80 | 81 | describe('Filter', () => { 82 | it('should return "all"', done => { 83 | todoPresenter.filter$.pipe(skip(1)).subscribe(filter => { 84 | expect(filter).toEqual('all'); 85 | done(); 86 | }); 87 | 88 | todoPresenter.getAllTodos(); 89 | }); 90 | }); 91 | }); 92 | 93 | describe('Get Completed Todos', () => { 94 | describe('Todos', () => { 95 | it('should return proper todos from repository', done => { 96 | todoPresenter.todos$.pipe(skip(1)).subscribe(todos => { 97 | expect(todos).toHaveLength(1); 98 | 99 | expect(todos[0].id).toEqual(item1.id); 100 | expect(todos[0].name).toEqual(item1.title); 101 | expect(todos[0].completed).toEqual(item1.completed); 102 | expect(todos[0].editing).toBeFalsy(); 103 | 104 | done(); 105 | }); 106 | 107 | todoPresenter.getCompletedTodos(); 108 | }); 109 | }); 110 | 111 | describe('Active Todos Count', () => { 112 | it('should return proper counts of todos', done => { 113 | todoPresenter.activeTodosCount$.pipe(skip(1)).subscribe(count => { 114 | expect(count).toEqual(1); 115 | done(); 116 | }); 117 | 118 | todoPresenter.getCompletedTodos(); 119 | }); 120 | }); 121 | 122 | describe('Filter', () => { 123 | it('should return "completed"', done => { 124 | todoPresenter.filter$.pipe(skip(1)).subscribe(filter => { 125 | expect(filter).toEqual('completed'); 126 | done(); 127 | }); 128 | 129 | todoPresenter.getCompletedTodos(); 130 | }); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | Publish it 2 | * Write article on medium 3 | 4 | * Add exception handling 5 | * Provide exception model in domain 6 | * Make working example in main app 7 | * resources 8 | https://blog.angular-university.io/rxjs-error-handling/ 9 | 10 | * On npm as separate packages with domain / data for use by all apps 11 | https://itnext.io/step-by-step-building-and-publishing-an-npm-typescript-package-44fe7164964c 12 | 13 | * Have it in monorepo with core and several apps using it 14 | 15 | Give following Presentation layers 16 | 17 | * Web js 18 | * Angular 19 | * React 20 | * Svelte 21 | * Ionic 22 | * Terminal UI nodejs app 23 | * https://github.com/druc/todo-cli 24 | * https://github.com/dalenguyen/todo-cli 25 | * api inspiration https://github.com/darrikonn/td-cli/blob/master/API.md 26 | * https://itnext.io/how-to-create-your-own-typescript-cli-with-node-js-1faf7095ef89 27 | * https://itnext.io/create-your-own-advanced-cli-with-typescript-5868ae3df397 28 | * https://www.youtube.com/watch?v=v2GKt39-LPA 29 | * https://github.com/SBoudrias/Inquirer.js 30 | * blessed nodejs library 31 | * Desktop App via Electron 32 | * photon ui kit (macos look like) 33 | * microsoft fabric https://developer.microsoft.com/en-us/fabric#/controls/web/checkbox 34 | * tutorial on fabric with todo app step by step https://developer.microsoft.com/en-us/fabric#/get-started 35 | * integration with angular / react / vue 36 | 37 | 38 | Provide alternative view frameworks 39 | 40 | * Integration with just redux https://redux.js.org 41 | * todo app examples: https://redux.js.org/introduction/core-concepts 42 | * RxJS + STATE streams (https://medium.com/angular-in-depth/angular-you-may-not-need-ngrx-e80546cc56ee) 43 | * https://auth0.com/blog/ngrx-facades-pros-and-cons/ 44 | * Redux / MobX 45 | 46 | * Firebase repo and authentication to todo 47 | https://www.freecodecamp.org/news/creating-a-crud-to-do-app-using-ionic-4/ 48 | 49 | 50 | 51 | Resources 52 | 53 | DDD Articles and repos 54 | 55 | Repos 56 | * https://github.com/stemmlerjs/white-label 57 | * https://github.com/stemmlerjs/ddd-forum 58 | 59 | * https://medium.com/inato/expressive-error-handling-in-typescript-and-benefits-for-domain-driven-design-70726e061c86 60 | * https://khalilstemmler.com/articles/enterprise-typescript-nodejs/handling-errors-result-class/ 61 | * https://khalilstemmler.com/articles/enterprise-typescript-nodejs/functional-error-handling/ 62 | * https://khalilstemmler.com/articles/typescript-value-object/ 63 | * https://khalilstemmler.com/wiki/anemic-domain-model/ 64 | * https://khalilstemmler.com/articles/enterprise-typescript-nodejs/application-layer-use-cases/ 65 | 66 | 67 | 68 | *Other project i started* 69 | https://github.com/benarso/angular-cleaner-architecture/blob/master/src/app/todo/presentation/presenter/todo-presenter.service.ts 70 | 71 | *Tools to try* 72 | 73 | Mono repos 74 | 75 | https://nx.dev/angular/tutorial/01-create-application 76 | 77 | https://nx.dev/angular/getting-started/getting-started 78 | 79 | *From mail sent to myself* 80 | 81 | https://github.com/rakshit444/news-sample-app 82 | 83 | https://github.com/benarso/angular-cleaner-architecture/tree/master/src/app/todo/presentation 84 | 85 | https://github.com/benarso/angular-cleaner-architecture/tree/master/src/app/todo/presentation 86 | 87 | https://github.com/pnowak2/angular-architecture-ideas/blob/master/src/app/tutorial/data/repository/flexible-pokemon.repository.ts 88 | 89 | https://github.com/pnowak2/angular-architecture-ideas/tree/master/src/app 90 | 91 | https://jasonwatmore.com/post/2019/02/13/react-rxjs-communicating-between-components-with-observable-subject 92 | 93 | *Opened Tabs* 94 | 95 | https://www.toptal.com/android/benefits-of-clean-architecture-android 96 | 97 | https://github.com/tomaszczura/location-registry/tree/master/app/src/main/java/com/astalos/locationregistry/domain 98 | 99 | https://medium.com/@thegiraffeclub/angular-clean-architecture-approach-fcfe32e983a5 100 | 101 | https://medium.com/intive-developers/approach-to-clean-architecture-in-angular-applications-hands-on-35145ceadc98? 102 | 103 | https://github.com/im-a-giraffe/angular-clean-architecture/blob/master/src/app/presentation/elephant-card-list/elephant-card-list.component.ts 104 | 105 | https://proandroiddev.com/multiple-ways-of-defining-clean-architecture-layers-bbb70afa5d4a 106 | 107 | https://github.com/igorwojda/Android-Showcase#architecture 108 | 109 | https://github.com/angularlicious/angular-architecture 110 | 111 | https://github.com/JasonGT/CleanArchitecture 112 | 113 | https://github.com/bufferapp/android-clean-architecture-boilerplate/tree/master/domain/src/main/java/org/buffer/android/boilerplate/domain 114 | 115 | https://github.com/benarso/angular-cleaner-architecture/tree/master/src/app 116 | 117 | https://github.com/phodal/clean-frontend/tree/master/src/app 118 | 119 | https://www.codecademy.com/articles/react-setup-i 120 | 121 | 122 | *Bookmarked to read later* 123 | 124 | https://medium.com/@thegiraffeclub/angular-clean-architecture-approach-fcfe32e983a5 125 | 126 | https://github.com/JasonGT/CleanArchitecture/tree/master/src/Application/Common 127 | 128 | https://proandroiddev.com/multiple-ways-of-defining-clean-architecture-layers-bbb70afa5d4a 129 | 130 | https://github.com/igorwojda/Android-Showcase#architecture 131 | 132 | https://github.com/bufferapp/android-clean-architecture-boilerplate#architecture 133 | 134 | https://github.com/angularlicious/angular-architecture 135 | 136 | https://github.com/bufferapp/android-clean-architecture-boilerplate/tree/master/domain/src/main/java/org/buffer/android/boilerplate/domain/interactor 137 | 138 | https://github.com/benarso/angular-cleaner-architecture/tree/master/src/app/core/domain 139 | 140 | https://www.freecodecamp.org/news/a-typescript-stab-at-clean-architecture-b51fbb16a304/ 141 | 142 | https://www.toptal.com/android/benefits-of-clean-architecture-android 143 | 144 | https://dev.to/phodal/clean-architecture-for-frontend-in-action-1aop 145 | 146 | https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html 147 | 148 | 149 | *Notes* 150 | 151 | singleusecase, observableusecase 152 | https://github.com/benarso/angular-cleaner-architecture/tree/master/src/app/core/domain 153 | 154 | consider using viewmodel as models of view, separate from domain models 155 | consider using presenter instead of facade 156 | 157 | https://github.com/benarso/angular-cleaner-architecture/blob/master/src/app/todo/presentation/presenter/todo-presenter.service.ts 158 | 159 | 160 | completable usecase idea of side effects run maybe ? (subscribes automatically) 161 | https://github.com/bufferapp/android-clean-architecture-boilerplate/blob/master/domain/src/main/java/org/buffer/android/boilerplate/domain/interactor/CompletableUseCase.kt 162 | -------------------------------------------------------------------------------- /src/features/todo/presentation/presenter/todo-default.presenter.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, forkJoin, Observable } from 'rxjs'; 2 | import { map, switchMap } from 'rxjs/operators'; 3 | import { TodoRepository } from '../../domain/repository/todo.repository'; 4 | import { AddTodoUseCase } from '../../domain/usecase/add-todo.usecase'; 5 | import { FilterTodosUseCase } from '../../domain/usecase/filter-todos.usecase'; 6 | import { GetActiveTodosCountUseCase } from '../../domain/usecase/get-active-todos-count.usecase'; 7 | import { GetActiveTodosUseCase } from '../../domain/usecase/get-active-todos.usecase'; 8 | import { GetAllTodosUseCase } from '../../domain/usecase/get-all-todos.usecase'; 9 | import { GetCompletedTodosUseCase } from '../../domain/usecase/get-completed-todos.usecase'; 10 | import { MarkAllTodosAsActiveUseCase } from '../../domain/usecase/mark-all-todos-as-active.usecase'; 11 | import { MarkAllTodosAsCompletedUseCase } from '../../domain/usecase/mark-all-todos-as-completed.usecase'; 12 | import { MarkTodoAsActiveUseCase } from '../../domain/usecase/mark-todo-as-active.usecase'; 13 | import { MarkTodoAsCompletedUseCase } from '../../domain/usecase/mark-todo-as-completed.usecase'; 14 | import { RemoveCompletedTodosUseCase } from '../../domain/usecase/remove-completed-todos.usecas'; 15 | import { RemoveTodoUseCase } from '../../domain/usecase/remove-todo-id.usecase'; 16 | import { TodoViewModelMapper } from '../mapper/todo.mapper'; 17 | import { TodoStateVM, TodoVM } from '../viewmodel/todos.viewmodel'; 18 | import { TodoPresenter } from './todo.presenter'; 19 | 20 | export class TodoDefaultPresenter implements TodoPresenter { 21 | todos$: Observable; 22 | activeTodosCount$: Observable; 23 | filter$: Observable; 24 | 25 | // internal state 26 | private state = new TodoStateVM(); 27 | private dispatch = new BehaviorSubject(this.state); 28 | private mapper = new TodoViewModelMapper(); 29 | 30 | // use cases 31 | private filterTodosUC: FilterTodosUseCase; 32 | private getAllTodosUC: GetAllTodosUseCase; 33 | private getCompletedTodosUC: GetCompletedTodosUseCase; 34 | private getActiveTodosUC: GetActiveTodosUseCase; 35 | private getActiveTodosCountUC: GetActiveTodosCountUseCase; 36 | private addTodoUC: AddTodoUseCase; 37 | private markTodoAsCompletedUC: MarkTodoAsCompletedUseCase; 38 | private markTodoAsActiveUC: MarkTodoAsActiveUseCase; 39 | private removeTodoUC: RemoveTodoUseCase; 40 | private removeCompletedTodosUC: RemoveCompletedTodosUseCase; 41 | private markAllTodosAsCompletedUC: MarkAllTodosAsCompletedUseCase; 42 | private markAllTodosAsActiveUC: MarkAllTodosAsActiveUseCase; 43 | 44 | constructor(private repository: TodoRepository) { 45 | this.filterTodosUC = new FilterTodosUseCase(this.repository); 46 | this.getAllTodosUC = new GetAllTodosUseCase(this.repository); 47 | this.getCompletedTodosUC = new GetCompletedTodosUseCase(this.repository); 48 | this.getActiveTodosUC = new GetActiveTodosUseCase(this.repository); 49 | this.getActiveTodosCountUC = new GetActiveTodosCountUseCase(this.repository); 50 | this.addTodoUC = new AddTodoUseCase(this.repository); 51 | this.markTodoAsCompletedUC = new MarkTodoAsCompletedUseCase(this.repository); 52 | this.markTodoAsActiveUC = new MarkTodoAsActiveUseCase(this.repository); 53 | this.markAllTodosAsCompletedUC = new MarkAllTodosAsCompletedUseCase(this.repository); 54 | this.markAllTodosAsActiveUC = new MarkAllTodosAsActiveUseCase(this.repository); 55 | this.removeTodoUC = new RemoveTodoUseCase(this.repository); 56 | this.removeCompletedTodosUC = new RemoveCompletedTodosUseCase(this.repository); 57 | 58 | // state selectors 59 | this.todos$ = this.dispatch.asObservable().pipe(map(state => state.todos)); 60 | this.filter$ = this.dispatch.asObservable().pipe(map(state => state.filter)); 61 | this.activeTodosCount$ = this.dispatch.asObservable().pipe(map(state => state.activeTodosCount)); 62 | } 63 | 64 | getAllTodos(): Observable { 65 | const todos$ = this.getAllTodosUC.execute().pipe( 66 | map(todos => todos.map(this.mapper.mapFrom)) 67 | ); 68 | const count$ = this.getActiveTodosCountUC.execute(); 69 | 70 | forkJoin(todos$, count$).subscribe(([todos, activeTodosCount]) => { 71 | this.dispatch.next( 72 | (this.state = { 73 | ...this.state, 74 | todos, 75 | filter: 'all', 76 | activeTodosCount, 77 | }), 78 | ); 79 | }); 80 | 81 | return todos$; 82 | } 83 | 84 | getCompletedTodos() { 85 | const todos$ = this.getCompletedTodosUC.execute(); 86 | const count$ = this.getActiveTodosCountUC.execute(); 87 | 88 | forkJoin(todos$, count$).subscribe(([todos, count]) => { 89 | this.dispatch.next( 90 | (this.state = { 91 | ...this.state, 92 | todos: todos.map(this.mapper.mapFrom), 93 | filter: 'completed', 94 | activeTodosCount: count, 95 | }), 96 | ); 97 | }); 98 | } 99 | 100 | getActiveTodos() { 101 | const todos$ = this.getActiveTodosUC.execute(); 102 | const count$ = this.getActiveTodosCountUC.execute(); 103 | 104 | forkJoin(todos$, count$).subscribe(([todos, count]) => { 105 | this.dispatch.next( 106 | (this.state = { 107 | ...this.state, 108 | todos: todos.map(this.mapper.mapFrom), 109 | filter: 'active', 110 | activeTodosCount: count, 111 | }), 112 | ); 113 | }); 114 | } 115 | 116 | addTodo(name: string): Observable { 117 | const add$ = this.addTodoUC.execute({ name }).pipe(map(this.mapper.mapFrom)); 118 | const count$ = this.getActiveTodosCountUC.execute(); 119 | const todos$ = this.filterTodosUC.execute({ filter: this.state.filter }).pipe( 120 | map(todos => todos.map(this.mapper.mapFrom)) 121 | ); 122 | 123 | add$.pipe( 124 | switchMap(() => forkJoin(count$, todos$)) 125 | ).subscribe(([activeTodosCount, todos]) => { 126 | this.dispatch.next( 127 | (this.state = { 128 | ...this.state, 129 | todos, 130 | activeTodosCount, 131 | }), 132 | ); 133 | }); 134 | 135 | return add$; 136 | } 137 | 138 | markTodoAsCompleted(id: string) { 139 | const mark$ = this.markTodoAsCompletedUC.execute({ id }); 140 | const count$ = this.getActiveTodosCountUC.execute(); 141 | const todos$ = this.filterTodosUC.execute({ filter: this.state.filter }); 142 | 143 | forkJoin(mark$, count$, todos$).subscribe(([, count, todos]) => { 144 | this.dispatch.next( 145 | (this.state = { 146 | ...this.state, 147 | todos: todos.map(this.mapper.mapFrom), 148 | activeTodosCount: count, 149 | }), 150 | ); 151 | }); 152 | } 153 | 154 | markTodoAsActive(id: string) { 155 | const mark$ = this.markTodoAsActiveUC.execute({ id }); 156 | const count$ = this.getActiveTodosCountUC.execute(); 157 | const todos$ = this.filterTodosUC.execute({ filter: this.state.filter }); 158 | 159 | forkJoin(mark$, count$, todos$).subscribe(([, count, todos]) => { 160 | this.dispatch.next( 161 | (this.state = { 162 | ...this.state, 163 | todos: todos.map(this.mapper.mapFrom), 164 | activeTodosCount: count, 165 | }), 166 | ); 167 | }); 168 | } 169 | 170 | markAllTodosAsCompleted() { 171 | const mark$ = this.markAllTodosAsCompletedUC.execute(); 172 | const count$ = this.getActiveTodosCountUC.execute(); 173 | const todos$ = this.filterTodosUC.execute({ filter: this.state.filter }); 174 | 175 | forkJoin(mark$, count$, todos$).subscribe(([, count, todos]) => { 176 | this.dispatch.next( 177 | (this.state = { 178 | ...this.state, 179 | todos: todos.map(this.mapper.mapFrom), 180 | activeTodosCount: count, 181 | }), 182 | ); 183 | }); 184 | } 185 | 186 | markAllTodosAsActive() { 187 | const mark$ = this.markAllTodosAsActiveUC.execute(); 188 | const count$ = this.getActiveTodosCountUC.execute(); 189 | const todos$ = this.filterTodosUC.execute({ filter: this.state.filter }); 190 | 191 | forkJoin(mark$, count$, todos$).subscribe(([, count, todos]) => { 192 | this.dispatch.next( 193 | (this.state = { 194 | ...this.state, 195 | todos: todos.map(this.mapper.mapFrom), 196 | activeTodosCount: count, 197 | }), 198 | ); 199 | }); 200 | } 201 | 202 | removeTodo(id: string) { 203 | const remove$ = this.removeTodoUC.execute({ id }); 204 | const count$ = this.getActiveTodosCountUC.execute(); 205 | const todos$ = this.filterTodosUC.execute({ filter: this.state.filter }); 206 | 207 | forkJoin(remove$, count$, todos$).subscribe(([, count, todos]) => { 208 | this.dispatch.next( 209 | (this.state = { 210 | ...this.state, 211 | todos: todos.map(this.mapper.mapFrom), 212 | activeTodosCount: count, 213 | }), 214 | ); 215 | }); 216 | } 217 | 218 | removeCompletedTodos() { 219 | const remove$ = this.removeCompletedTodosUC.execute(); 220 | const count$ = this.getActiveTodosCountUC.execute(); 221 | const todos$ = this.filterTodosUC.execute({ filter: this.state.filter }); 222 | 223 | forkJoin(remove$, count$, todos$).subscribe(([, count, todos]) => { 224 | this.dispatch.next( 225 | (this.state = { 226 | ...this.state, 227 | todos: todos.map(this.mapper.mapFrom), 228 | activeTodosCount: count, 229 | }), 230 | ); 231 | }); 232 | } 233 | 234 | private updateTodos(todos: TodoVM[]) { 235 | this.dispatch.next( 236 | (this.state = { 237 | ...this.state, 238 | todos, 239 | }), 240 | ); 241 | } 242 | } 243 | --------------------------------------------------------------------------------