├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── docs └── todo.md ├── jest.config.js ├── next-env.d.ts ├── next.config.js ├── package.json ├── public ├── favicon.ico └── vercel.svg ├── src ├── components │ └── error │ │ ├── error.module.scss │ │ └── error.tsx ├── config │ └── outputs.ts ├── modules │ └── todos │ │ ├── application │ │ ├── add-todo-form │ │ │ ├── add-todo-form.container.tsx │ │ │ ├── add-todo-form.module.scss │ │ │ └── add-todo-form.view.tsx │ │ ├── todo-item │ │ │ ├── todo-item.container.tsx │ │ │ ├── todo-item.module.scss │ │ │ └── todo-item.view.tsx │ │ ├── todo-list │ │ │ ├── todo-list.container.tsx │ │ │ ├── todo-list.module.scss │ │ │ └── todo-list.view.tsx │ │ ├── todo.ts │ │ └── todos.mapper.ts │ │ ├── domain │ │ ├── todo.ts │ │ ├── todos.actions.ts │ │ ├── todos.output.ts │ │ └── todos.test.ts │ │ └── infrastructure │ │ ├── todo.ts │ │ ├── todos.fakes.ts │ │ ├── todos.in-memory.ts │ │ └── todos.local-storage.ts ├── pages │ ├── _app.tsx │ └── index.tsx └── styles │ └── globals.css ├── tsconfig.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | "es2021": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:react/recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier", 13 | "next/core-web-vitals" 14 | ], 15 | "parser": "@typescript-eslint/parser", 16 | "settings": { 17 | "react": { 18 | "version": "detect" 19 | } 20 | }, 21 | "parserOptions": { 22 | "ecmaFeatures": { 23 | "jsx": true 24 | }, 25 | "ecmaVersion": 12, 26 | "sourceType": "module" 27 | }, 28 | "plugins": ["react", "@typescript-eslint"], 29 | "rules": { 30 | "@typescript-eslint/ban-ts-comment": ["off"], 31 | "eslint-disable-next-line react/no-unescaped-entities": ["off"], 32 | "@typescript-eslint/no-explicit-any": ["off"] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # ide 37 | .idea/ 38 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .next -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": false, 4 | "tabWidth": 4, 5 | "useTabs": true, 6 | "bracketSpacing": true, 7 | "arrowParens": "always" 8 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Hello, here is a repository to send you my vision of clean architecture (firstly on the front, then later on back) in different versions. 2 | 3 | I wrote an article to support the examples of this project: [https://www.hexa-web.fr/blog/hexagonal-architecture-front-end](https://www.hexa-web.fr/blog/hexagonal-architecture-front-end) 4 | To understand the clean architecture, you can also read my article on dependency inversion in front-end: [https://www.hexa-web.fr/blog/dependency-inversion-front-end](https://www.hexa-web.fr/blog/dependency-inversion-front-end) 5 | 6 | For these examples, I chose to use [Next.js](https://nextjs.org/) based on [React](https://reactjs.org/), but the whole point of this architecture is to be able to use it independently of the frameworks and libraries used (external dependencies). 7 | 8 | In order to have a simple example, I chose a simple subject: a todo list! 9 | 10 | To see the tasks that still need to be done on the project, go to the /docs/todo.md file 11 | 12 | If you have any questions, suggestions or anything else, don't hesitate to contact me! 13 | And if this repository has helped you, consider sharing it with your acquaintances. 14 | 15 | ## Summary 16 | 17 | 1. [Getting started](#getting-started) 18 | 2. [Clean architecture](#clean-architecture) 19 | 1. [Use case](#use-case) 20 | 2. [Primary port](#primary-port) 21 | 3. [Primary adapter](#primary-adapter) 22 | 4. [Secondary port](#secondary-port) 23 | 5. [Secondary adapter](#secondary-adapter) 24 | 3. [Resources](#resources) 25 | 26 | ## Getting started 27 | 28 | First, install the dependencies: 29 | 30 | ```bash 31 | npm install 32 | # or 33 | yarn install 34 | ``` 35 | 36 | -- 37 | 38 | Then run the development server: 39 | 40 | ```bash 41 | npm run dev 42 | # or 43 | yarn dev 44 | ``` 45 | 46 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 47 | 48 | -- 49 | 50 | To start unit tests: 51 | 52 | ```bash 53 | jest 54 | ``` 55 | 56 | -- 57 | 58 | To test the app online: 59 | [https://front-end-clean-architecture.netlify.app/](https://front-end-clean-architecture.netlify.app/) 60 | 61 | ## Clean architecture 62 | 63 | The hexagonal architecture, or architecture based on ports and adapters, is an architectural pattern used in the field of software design. It aims to create systems based on application components which are loosely coupled and which can be easily connected to their software environment by means of ports and adapters. These components are modular and interchangeable, which reinforces the consistency of processing and facilitates the automation of tests. 64 | 65 | There are three parts in the clean architecture: the application part (the primary ports and adapters), the domain (the use cases, the domain models, etc.) and the infrastructure part (the secondary ports and adapters). 66 | 67 | This architecture is based on the port / adapter pattern and the dependency inversion principle. 68 | 69 | _By documenting you on clean architecture (or hexagonal architecture). You will find different names for these parts. The names chosen here are personal, the goal being that they are understandable._ 70 | 71 | ### Use case 72 | The uses cases define the actions of your users. The goal is not to use any framework or libraries in these elements (in order to keep a logic not coupled to these tools). 73 | 74 | On the front, they can be represented by function, by class written in JS or TS. With React, it is possible to use redux for this part. 75 | 76 | In case redux is used, the actions are the use-cases, the state is one of the models, and the selectors are used to map. 77 | 78 | ### Primary port 79 | The primary port is used to establish a contract between the primary adapter and the use cases. For this, an interface can be created. In practice, the use case is also considered a primary port. 80 | 81 | ### Primary adapter 82 | Then, the implementation of these interfaces are used to dialogue with the domain: the first is what we call the primary adapters. Their goal is to trigger the execution of use cases. For example on the front, these adapters can be the React components that perform triggers an action (redux or not). 83 | 84 | ### Secondary port 85 | The secondary port is used to establish a contract between the secondary adapter and the use cases. For this, we usually create an interface. This interface is used directly in the use case. 86 | 87 | _Tips: you can use dependency injection for that, some state management libraries allow you to do that. For example with [redux-thunk](https://github.com/reduxjs/redux-thunk#injecting-a-custom-argument) and [redux-observable](https://redux-observable.js.org/docs/recipes/InjectingDependenciesIntoEpics.html) it is possible to pass "extraArguments" which will be directly available in the redux actions. In "vanilla", there is also [InversifyJS](https://github.com/inversify/InversifyJS)._ 88 | 89 | ### Secondary adapter 90 | The second implementation of interfaces (ports) is called secondary adapters. They are called by the use cases. For example in front, these adapters can be the HTTP requests, the access to the data present in the local-storage, etc. 91 | 92 | ## Resources 93 | In english : 94 | - [Hexagonal architecture in front-end](https://www.hexa-web.fr/blog/hexagonal-architecture-front-end) 95 | - [Dependency inversion in front-end](https://www.hexa-web.fr/blog/dependency-inversion-front-end) 96 | - [Hexagonal architecture by Alistair Cockburn](https://alistair.cockburn.us/hexagonal-architecture/) 97 | 98 | In french : 99 | - [Architecture hexagonale en front-end](https://www.hexa-web.fr/blog/hexagonal-architecture-front-end) 100 | - [Inversion de dépendances en front-end](https://www.hexa-web.fr/blog/dependency-inversion-front-end) 101 | - [Slack de Wealcome](https://wealcome.slack.com/) 102 | - [La Clean Architecture : catalyseur de productivité](https://medium.com/@mickalwegerich/la-clean-architecture-catalyseur-de-productivit%C3%A9-68ff61aa38ff) 103 | - [Architecture Hexagonale : trois principes et un exemple d’implémentation](https://blog.octo.com/architecture-hexagonale-trois-principes-et-un-exemple-dimplementation/) 104 | - [Architecture Hexagonale : le guide pratique pour une clean architecture](https://beyondxscratch.com/fr/2018/09/11/architecture-hexagonale-le-guide-pratique-pour-une-clean-architecture/) 105 | -------------------------------------------------------------------------------- /docs/todo.md: -------------------------------------------------------------------------------- 1 | # Todo 2 | 3 | ## Main features 4 | - [x] implement the action to list todos 5 | - [x] implement the action to add a todo 6 | - [x] implement the action to complete a todo 7 | - [x] implement the action to remove a todo 8 | 9 | ## Ideas 10 | - [ ] implement the action to change the order of todos (drag and drop) 11 | 12 | ## Other versions 13 | - [ ] use Redux 14 | - [x] use LocalStorage 15 | - [ ] create an API with NestJS 16 | 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require("ts-jest/utils"); 2 | const { compilerOptions } = require("./tsconfig.json"); 3 | 4 | module.exports = { 5 | preset: "ts-jest", 6 | testEnvironment: "node", 7 | roots: [""], 8 | modulePaths: [""], 9 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths), 10 | testMatch: ["/src/**/*?(*.)+(spec|test).[jt]s?(x)"], 11 | }; 12 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clean-archi-front", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "npx eslint --fix \"src/**/*.{js,ts,tsx}\"", 10 | "format": "prettier --write \"src/**/*.{js,ts,tsx}\"", 11 | "test": "jest" 12 | }, 13 | "dependencies": { 14 | "next": "11.1.2", 15 | "react": "17.0.2", 16 | "react-dom": "17.0.2", 17 | "sass": "^1.43.2" 18 | }, 19 | "devDependencies": { 20 | "@types/jest": "^27.0.2", 21 | "@types/react": "17.0.30", 22 | "@typescript-eslint/eslint-plugin": "^5.1.0", 23 | "eslint": "^8.0.1", 24 | "eslint-config-next": "11.1.2", 25 | "eslint-config-prettier": "^8.3.0", 26 | "jest": "^27.3.1", 27 | "prettier": "^2.4.1", 28 | "ts-jest": "^27.0.7", 29 | "typescript": "4.4.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dimitridumont/clean-architecture-front-end/50cba1ea2dea107e7b435ebf64a283c41b4fad50/public/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/error/error.module.scss: -------------------------------------------------------------------------------- 1 | .error { 2 | text-align: center; 3 | margin-top: 10px; 4 | color: #ff5353; 5 | } -------------------------------------------------------------------------------- /src/components/error/error.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import styles from "./error.module.scss" 3 | 4 | interface Props { 5 | error: string 6 | } 7 | 8 | export const Error = ({ error }: Props) => 9 | error ?
{error}
: <> 10 | -------------------------------------------------------------------------------- /src/config/outputs.ts: -------------------------------------------------------------------------------- 1 | import { TodosLocalStorage } from "@/modules/todos/infrastructure/todos.local-storage" 2 | 3 | export const outputs = { 4 | todosOutput: new TodosLocalStorage(), 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/todos/application/add-todo-form/add-todo-form.container.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react" 2 | import { AddTodoFormView } from "@/modules/todos/application/add-todo-form/add-todo-form.view" 3 | import { addTodo } from "@/modules/todos/domain/todos.actions" 4 | import { outputs } from "@/config/outputs" 5 | import { Todo } from "@/modules/todos/application/todo" 6 | import { Todo as TodoDomain } from "@/modules/todos/domain/todo" 7 | import { mapToApplicationModel } from "@/modules/todos/application/todos.mapper" 8 | 9 | interface Props { 10 | setTodos: (todos: Todo[]) => void 11 | } 12 | 13 | export const AddTodoFormContainer = ({ setTodos }: Props) => { 14 | const [todoTitle, setTodoTitle] = useState("") 15 | const [errorToAddTodo, setErrorToAddTodo] = useState("") 16 | 17 | const onChangeTodoTitle = (event: any) => { 18 | const title: string = event.target.value 19 | 20 | setTodoTitle(title) 21 | } 22 | 23 | const onSubmit = async (event: any) => { 24 | event.preventDefault() 25 | 26 | try { 27 | const todos: TodoDomain[] = await addTodo({ 28 | todosOutput: outputs.todosOutput, 29 | todoTitle, 30 | }) 31 | 32 | setTodos(mapToApplicationModel(todos)) 33 | 34 | setTodoTitle("") 35 | setErrorToAddTodo("") 36 | } catch (error: any) { 37 | setErrorToAddTodo(error.message) 38 | } 39 | } 40 | 41 | return ( 42 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/modules/todos/application/add-todo-form/add-todo-form.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | text-align: center; 3 | margin: 0 auto; 4 | width: 95%; 5 | 6 | @media screen and (min-width: 768px) { 7 | width: 65%; 8 | } 9 | 10 | @media screen and (min-width: 1024px) { 11 | width: 30%; 12 | } 13 | 14 | input { 15 | width: 100%; 16 | padding: 1rem 2rem; 17 | border: 1px solid #e5e5e5; 18 | font-size: 16px; 19 | } 20 | } -------------------------------------------------------------------------------- /src/modules/todos/application/add-todo-form/add-todo-form.view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import styles from "./add-todo-form.module.scss" 3 | import { Error } from "@/components/error/error" 4 | 5 | interface Props { 6 | onSubmit: (event: any) => void 7 | todoTitle: string 8 | onChangeTodoTitle: (event: any) => void 9 | errorToAddTodo: string 10 | } 11 | 12 | export const AddTodoFormView = ({ 13 | onSubmit, 14 | todoTitle, 15 | onChangeTodoTitle, 16 | errorToAddTodo, 17 | }: Props) => { 18 | return ( 19 |
20 | 27 | 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/todos/application/todo-item/todo-item.container.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react" 2 | import { Todo } from "@/modules/todos/application/todo" 3 | import { TodoItemView } from "@/modules/todos/application/todo-item/todo-item.view" 4 | import { 5 | removeTodo, 6 | toggleCompleteTodo, 7 | } from "@/modules/todos/domain/todos.actions" 8 | import { outputs } from "@/config/outputs" 9 | import { Todo as TodoDomain } from "@/modules/todos/domain/todo" 10 | import { mapToApplicationModel } from "@/modules/todos/application/todos.mapper" 11 | 12 | interface Props { 13 | todo: Todo 14 | setTodos: (todos: Todo[]) => void 15 | } 16 | 17 | export const TodoItemContainer = ({ todo, setTodos }: Props) => { 18 | const [errorToCompleteTodo, setErrorToCompleteTodo] = useState("") 19 | const [errorToRemoveTodo, setErrorToRemoveTodo] = useState("") 20 | 21 | const _completeTodo = async () => { 22 | try { 23 | const todos: TodoDomain[] = await toggleCompleteTodo({ 24 | todosOutput: outputs.todosOutput, 25 | todoTitle: todo.title, 26 | }) 27 | 28 | setTodos(mapToApplicationModel(todos)) 29 | setErrorToCompleteTodo("") 30 | } catch (error: any) { 31 | setErrorToCompleteTodo(error.message) 32 | } 33 | } 34 | 35 | const _removeTodo = async (event: any) => { 36 | event.preventDefault() 37 | 38 | try { 39 | const todos: TodoDomain[] = await removeTodo({ 40 | todosOutput: outputs.todosOutput, 41 | todoTitle: todo.title, 42 | }) 43 | 44 | setTodos(mapToApplicationModel(todos)) 45 | setErrorToRemoveTodo("") 46 | } catch (error: any) { 47 | setErrorToRemoveTodo(error.message) 48 | } 49 | } 50 | 51 | return ( 52 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/modules/todos/application/todo-item/todo-item.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | list-style: none; 3 | border-top: 1px solid #e5e5e5; 4 | cursor: pointer; 5 | padding: 1rem 2rem; 6 | text-decoration: none; 7 | overflow: hidden; 8 | overflow-wrap: break-word; 9 | } 10 | 11 | .isCompleted { 12 | text-decoration: line-through; 13 | } 14 | 15 | .error { 16 | text-align: center; 17 | margin-top: 10px; 18 | color: #ff5353; 19 | } -------------------------------------------------------------------------------- /src/modules/todos/application/todo-item/todo-item.view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Todo } from "@/modules/todos/application/todo" 3 | import styles from "./todo-item.module.scss" 4 | import { Error } from "@/components/error/error" 5 | 6 | interface Props { 7 | todo: Todo 8 | completeTodo: () => void 9 | removeTodo: (event: any) => void 10 | errorToCompleteTodo: string 11 | errorToRemoveTodo: string 12 | } 13 | 14 | export const TodoItemView = ({ 15 | todo, 16 | completeTodo, 17 | removeTodo, 18 | errorToCompleteTodo, 19 | errorToRemoveTodo, 20 | }: Props) => { 21 | return ( 22 |
  • 31 | {todo.title} 32 | 33 | 34 | 35 |
  • 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/todos/application/todo-list/todo-list.container.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react" 2 | import { Todo as TodoDomain } from "@/modules/todos/domain/todo" 3 | import { TodoListView } from "@/modules/todos/application/todo-list/todo-list.view" 4 | import { getTodos } from "@/modules/todos/domain/todos.actions" 5 | import { outputs } from "@/config/outputs" 6 | import { Todo } from "@/modules/todos/application/todo" 7 | import { mapToApplicationModel } from "@/modules/todos/application/todos.mapper" 8 | 9 | export const TodoListContainer = () => { 10 | const [todos, setTodos] = useState([]) 11 | const [errorToGetTodos, setErrorToGetTodos] = useState("") 12 | 13 | useEffect(() => { 14 | _getTodos() 15 | }, []) 16 | 17 | const _getTodos = async () => { 18 | try { 19 | const todosDomain: TodoDomain[] = await getTodos({ 20 | todosOutput: outputs.todosOutput, 21 | }) 22 | 23 | const todos: Todo[] = mapToApplicationModel(todosDomain) 24 | 25 | setTodos(todos) 26 | setErrorToGetTodos("") 27 | } catch (error: any) { 28 | setErrorToGetTodos(error.message) 29 | } 30 | } 31 | 32 | return ( 33 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/todos/application/todo-list/todo-list.module.scss: -------------------------------------------------------------------------------- 1 | .title { 2 | text-align: center; 3 | color: rgb(252, 155, 57); 4 | opacity: 0.6; 5 | font-size: 38px; 6 | } 7 | 8 | .container { 9 | background-color: #fff; 10 | padding: 0; 11 | margin: 0 auto; 12 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); 13 | 14 | width: 95%; 15 | 16 | @media screen and (min-width: 768px) { 17 | width: 65%; 18 | } 19 | 20 | @media screen and (min-width: 1024px) { 21 | width: 30%; 22 | } 23 | } 24 | 25 | .information { 26 | text-align: center; 27 | margin: 32px auto; 28 | font-size: 14px; 29 | color: #888888; 30 | } -------------------------------------------------------------------------------- /src/modules/todos/application/todo-list/todo-list.view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Todo } from "@/modules/todos/application/todo" 3 | import styles from "./todo-list.module.scss" 4 | import { AddTodoFormContainer } from "@/modules/todos/application/add-todo-form/add-todo-form.container" 5 | import { TodoItemContainer } from "@/modules/todos/application/todo-item/todo-item.container" 6 | import { Error } from "@/components/error/error" 7 | 8 | interface Props { 9 | todos: Todo[] 10 | errorToGetTodos: string 11 | setTodos: (todos: Todo[]) => void 12 | } 13 | 14 | export const TodoListView = ({ todos, errorToGetTodos, setTodos }: Props) => { 15 | return ( 16 | <> 17 |

    Todos

    18 | 19 | 20 | 21 | 22 | 23 |
      24 | {todos.map((todo: Todo) => ( 25 | 30 | ))} 31 |
    32 | 33 |
    34 | Left click to complete todo 35 |
    36 | Right click or long touch on mobile to remove todo 37 |
    38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/todos/application/todo.ts: -------------------------------------------------------------------------------- 1 | export interface Todo { 2 | title: string 3 | isCompleted: boolean 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/todos/application/todos.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Todo as TodoDomain } from "@/modules/todos/domain/todo" 2 | import { Todo } from "@/modules/todos/application/todo" 3 | 4 | export const mapToApplicationModel = (todosDomain: TodoDomain[]): Todo[] => { 5 | return todosDomain.map((todoDomain: TodoDomain) => ({ 6 | title: todoDomain.title, 7 | isCompleted: todoDomain.isDone, 8 | })) 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/todos/domain/todo.ts: -------------------------------------------------------------------------------- 1 | export interface Todo { 2 | title: string 3 | isDone: boolean 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/todos/domain/todos.actions.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from "@/modules/todos/domain/todo" 2 | import { TodosOutput } from "@/modules/todos/domain/todos.output" 3 | 4 | export const getTodos = async ({ 5 | todosOutput, 6 | }: { 7 | todosOutput: TodosOutput 8 | }): Promise => { 9 | try { 10 | return await todosOutput.getTodos() 11 | } catch (error: any) { 12 | throw new Error(error) 13 | } 14 | } 15 | 16 | export const addTodo = async ({ 17 | todosOutput, 18 | todoTitle, 19 | }: { 20 | todosOutput: TodosOutput 21 | todoTitle: string 22 | }): Promise => { 23 | try { 24 | return await todosOutput.addTodo({ todoTitle }) 25 | } catch (error: any) { 26 | throw new Error(error) 27 | } 28 | } 29 | 30 | export const toggleCompleteTodo = async ({ 31 | todosOutput, 32 | todoTitle, 33 | }: { 34 | todosOutput: TodosOutput 35 | todoTitle: string 36 | }): Promise => { 37 | try { 38 | return await todosOutput.toggleCompleteTodo({ todoTitle }) 39 | } catch (error: any) { 40 | throw new Error(error) 41 | } 42 | } 43 | 44 | export const removeTodo = async ({ 45 | todosOutput, 46 | todoTitle, 47 | }: { 48 | todosOutput: TodosOutput 49 | todoTitle: string 50 | }): Promise => { 51 | try { 52 | return await todosOutput.removeTodo({ todoTitle }) 53 | } catch (error: any) { 54 | throw new Error(error) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/modules/todos/domain/todos.output.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from "@/modules/todos/domain/todo" 2 | 3 | export interface TodosOutput { 4 | getTodos(): Promise 5 | addTodo({ todoTitle }: { todoTitle: string }): Promise 6 | toggleCompleteTodo({ todoTitle }: { todoTitle: string }): Promise 7 | removeTodo({ todoTitle }: { todoTitle: string }): Promise 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/todos/domain/todos.test.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from "@/modules/todos/domain/todo" 2 | import { Todo as TodoInfraModel } from "@/modules/todos/infrastructure/todo" 3 | import { 4 | addTodo, 5 | getTodos, 6 | removeTodo, 7 | toggleCompleteTodo, 8 | } from "@/modules/todos/domain/todos.actions" 9 | import { TodosInMemory } from "@/modules/todos/infrastructure/todos.in-memory" 10 | import { todosInfrastructureFakes } from "@/modules/todos/infrastructure/todos.fakes" 11 | 12 | describe("[todos] unit tests", () => { 13 | const todosOutput = new TodosInMemory() 14 | 15 | beforeEach(() => { 16 | todosOutput.setTodos([]) 17 | }) 18 | 19 | describe("when the user wants to get his todos", () => { 20 | it("should get them without error", async () => { 21 | todosOutput.setTodos(todosInfrastructureFakes) 22 | 23 | const todos: Todo[] = await getTodos({ 24 | todosOutput, 25 | }) 26 | 27 | const expectedTodos: Todo[] = todosInfrastructureFakes.map( 28 | (infraModel: TodoInfraModel) => ({ 29 | title: infraModel.title, 30 | isDone: infraModel.isOk, 31 | }) 32 | ) 33 | 34 | expect(todos).toEqual(expectedTodos) 35 | }) 36 | 37 | it("shouldn't get them and should throw error", async () => { 38 | todosOutput.setTodos(undefined) 39 | 40 | await expect( 41 | getTodos({ 42 | todosOutput, 43 | }) 44 | ).rejects.toThrowError() 45 | }) 46 | }) 47 | 48 | describe("when the user wants to add a todo", () => { 49 | it("should add it to his empty todos", async () => { 50 | const todos: Todo[] = await addTodo({ 51 | todosOutput, 52 | todoTitle: "Prepare for the meeting", 53 | }) 54 | 55 | const expectedTodos: Todo[] = [ 56 | { 57 | title: "Prepare for the meeting", 58 | isDone: false, 59 | }, 60 | ] 61 | 62 | expect(todos).toEqual(expectedTodos) 63 | }) 64 | 65 | it("should add it to his existing todos", async () => { 66 | todosOutput.setTodos(todosInfrastructureFakes) 67 | 68 | const todos: Todo[] = await addTodo({ 69 | todosOutput, 70 | todoTitle: "Prepare the course", 71 | }) 72 | 73 | const expectedTodos: Todo[] = [ 74 | ...todosOutput.mapToDomainModel(todosInfrastructureFakes), 75 | { 76 | title: "Prepare the course", 77 | isDone: false, 78 | }, 79 | ] 80 | 81 | expect(todos).toEqual(expectedTodos) 82 | }) 83 | 84 | it("shouldn't add it to his existing todos if the todo is already existed", async () => { 85 | todosOutput.setTodos(todosInfrastructureFakes) 86 | 87 | const todos: Todo[] = await addTodo({ 88 | todosOutput, 89 | todoTitle: "Prepare for the meeting", 90 | }) 91 | 92 | const expectedTodos: Todo[] = [ 93 | ...todosOutput.mapToDomainModel(todosInfrastructureFakes), 94 | ] 95 | 96 | expect(todos).toEqual(expectedTodos) 97 | }) 98 | 99 | it("shouldn't add it and should throw error", async () => { 100 | todosOutput.setTodos(undefined) 101 | 102 | await expect( 103 | addTodo({ 104 | todosOutput, 105 | todoTitle: "Prepare the course", 106 | }) 107 | ).rejects.toThrowError() 108 | }) 109 | }) 110 | 111 | describe("when the user wants to complete one of his todo", () => { 112 | it("should complete it", async () => { 113 | todosOutput.setTodos(todosInfrastructureFakes) 114 | 115 | const todos: Todo[] = await toggleCompleteTodo({ 116 | todosOutput, 117 | todoTitle: "Prepare for the meeting", 118 | }) 119 | 120 | const expectedTodos: Todo[] = [ 121 | { 122 | title: "Prepare for the meeting", 123 | isDone: true, 124 | }, 125 | { 126 | title: "Walk the dog", 127 | isDone: true, 128 | }, 129 | { 130 | title: "Start the project", 131 | isDone: false, 132 | }, 133 | ] 134 | 135 | expect(todos).toEqual(expectedTodos) 136 | }) 137 | 138 | it("should not completed if the todo is already completed", async () => { 139 | todosOutput.setTodos(todosInfrastructureFakes) 140 | 141 | const todos: Todo[] = await toggleCompleteTodo({ 142 | todosOutput, 143 | todoTitle: "Walk the dog", 144 | }) 145 | 146 | const expectedTodos: Todo[] = [ 147 | { 148 | title: "Prepare for the meeting", 149 | isDone: false, 150 | }, 151 | { 152 | title: "Walk the dog", 153 | isDone: false, 154 | }, 155 | { 156 | title: "Start the project", 157 | isDone: false, 158 | }, 159 | ] 160 | 161 | expect(todos).toEqual(expectedTodos) 162 | }) 163 | 164 | it("shouldn't completed it if the todo doesn't exit", async () => { 165 | todosOutput.setTodos(todosInfrastructureFakes) 166 | 167 | const todos: Todo[] = await toggleCompleteTodo({ 168 | todosOutput, 169 | todoTitle: "A todo that does not exist", 170 | }) 171 | 172 | const expectedTodos: Todo[] = [ 173 | { 174 | title: "Prepare for the meeting", 175 | isDone: false, 176 | }, 177 | { 178 | title: "Walk the dog", 179 | isDone: true, 180 | }, 181 | { 182 | title: "Start the project", 183 | isDone: false, 184 | }, 185 | ] 186 | 187 | expect(todos).toEqual(expectedTodos) 188 | }) 189 | 190 | it("shouldn't completed it and should throw error", async () => { 191 | todosOutput.setTodos(undefined) 192 | 193 | await expect( 194 | toggleCompleteTodo({ 195 | todosOutput, 196 | todoTitle: "Prepare the course", 197 | }) 198 | ).rejects.toThrowError() 199 | }) 200 | }) 201 | 202 | describe("when the user wants to remove one of his todo", () => { 203 | it("should remove it", async () => { 204 | todosOutput.setTodos(todosInfrastructureFakes) 205 | 206 | const todos: Todo[] = await removeTodo({ 207 | todosOutput, 208 | todoTitle: "Prepare for the meeting", 209 | }) 210 | 211 | const expectedTodos: Todo[] = [ 212 | { 213 | title: "Walk the dog", 214 | isDone: true, 215 | }, 216 | { 217 | title: "Start the project", 218 | isDone: false, 219 | }, 220 | ] 221 | 222 | expect(todos).toEqual(expectedTodos) 223 | }) 224 | 225 | it("shouldn't remove it if the todo doesn't exist", async () => { 226 | todosOutput.setTodos(todosInfrastructureFakes) 227 | 228 | const todos: Todo[] = await removeTodo({ 229 | todosOutput, 230 | todoTitle: "A todo that does not exist", 231 | }) 232 | 233 | const expectedTodos: Todo[] = [ 234 | { 235 | title: "Prepare for the meeting", 236 | isDone: false, 237 | }, 238 | { 239 | title: "Walk the dog", 240 | isDone: true, 241 | }, 242 | { 243 | title: "Start the project", 244 | isDone: false, 245 | }, 246 | ] 247 | 248 | expect(todos).toEqual(expectedTodos) 249 | }) 250 | 251 | it("shouldn't remove it and should throw error", async () => { 252 | todosOutput.setTodos(undefined) 253 | 254 | await expect( 255 | removeTodo({ 256 | todosOutput, 257 | todoTitle: "Prepare the course", 258 | }) 259 | ).rejects.toThrowError() 260 | }) 261 | }) 262 | }) 263 | -------------------------------------------------------------------------------- /src/modules/todos/infrastructure/todo.ts: -------------------------------------------------------------------------------- 1 | export interface Todo { 2 | title: string 3 | isOk: boolean 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/todos/infrastructure/todos.fakes.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from "@/modules/todos/infrastructure/todo" 2 | 3 | export const todosInfrastructureFakes: Todo[] = [ 4 | { 5 | title: "Prepare for the meeting", 6 | isOk: false, 7 | }, 8 | { 9 | title: "Walk the dog", 10 | isOk: true, 11 | }, 12 | { 13 | title: "Start the project", 14 | isOk: false, 15 | }, 16 | ] 17 | -------------------------------------------------------------------------------- /src/modules/todos/infrastructure/todos.in-memory.ts: -------------------------------------------------------------------------------- 1 | import { TodosOutput } from "@/modules/todos/domain/todos.output" 2 | import { Todo } from "@/modules/todos/domain/todo" 3 | import { Todo as TodoInfra } from "@/modules/todos/infrastructure/todo" 4 | 5 | export class TodosInMemory implements TodosOutput { 6 | private todos: TodoInfra[] | undefined = [] 7 | 8 | setTodos(todos: TodoInfra[] | undefined): void { 9 | this.todos = todos ? [...todos] : undefined 10 | } 11 | 12 | mapToDomainModel(infraModel: TodoInfra[]): Todo[] { 13 | return infraModel.map((infraModel: TodoInfra) => ({ 14 | title: infraModel.title, 15 | isDone: infraModel.isOk, 16 | })) 17 | } 18 | 19 | getTodos(): Promise { 20 | if (!this.todos) { 21 | throw new Error("Please create a todo") 22 | } 23 | 24 | const todos: Todo[] = this.mapToDomainModel(this.todos) 25 | 26 | return Promise.resolve(todos) 27 | } 28 | 29 | addTodo({ todoTitle }: { todoTitle: string }): Promise { 30 | if (!this.todos) 31 | throw new Error("An error occurred while adding the todo") 32 | 33 | const isTodoExists: boolean = 34 | this.todos.find((todo: TodoInfra) => todo.title === todoTitle) !== 35 | undefined 36 | 37 | if (!isTodoExists) { 38 | const todo: TodoInfra = { 39 | title: todoTitle, 40 | isOk: false, 41 | } 42 | 43 | this.todos.push(todo) 44 | } 45 | 46 | const todos: Todo[] = this.mapToDomainModel(this.todos) 47 | 48 | return Promise.resolve(todos) 49 | } 50 | 51 | toggleCompleteTodo({ todoTitle }: { todoTitle: string }): Promise { 52 | if (!this.todos) 53 | throw new Error("An error occurred while modifying the todo") 54 | 55 | this.todos = [ 56 | ...this.todos.map((todo: TodoInfra) => { 57 | return todo.title === todoTitle 58 | ? { 59 | ...todo, 60 | isOk: !todo.isOk, 61 | } 62 | : todo 63 | }), 64 | ] 65 | 66 | const todos: Todo[] = this.mapToDomainModel(this.todos) 67 | 68 | return Promise.resolve(todos) 69 | } 70 | 71 | removeTodo({ todoTitle }: { todoTitle: string }): Promise { 72 | if (!this.todos) 73 | throw new Error("n error occurred while deleting the task") 74 | 75 | this.todos = [ 76 | ...this.todos.filter((todo: TodoInfra) => todo.title !== todoTitle), 77 | ] 78 | 79 | const todos: Todo[] = this.mapToDomainModel(this.todos) 80 | 81 | return Promise.resolve(todos) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/modules/todos/infrastructure/todos.local-storage.ts: -------------------------------------------------------------------------------- 1 | import { TodosOutput } from "@/modules/todos/domain/todos.output" 2 | import { Todo } from "@/modules/todos/domain/todo" 3 | 4 | export class TodosLocalStorage implements TodosOutput { 5 | getLocalTodos(): Todo[] { 6 | const localTodos: string | null = localStorage.getItem("todos") 7 | 8 | return localTodos ? JSON.parse(localTodos) : [] 9 | } 10 | 11 | setLocalTodos(todos: Todo[]): void { 12 | localStorage.setItem("todos", JSON.stringify(todos)) 13 | } 14 | 15 | getTodos(): Promise { 16 | const todos: Todo[] = this.getLocalTodos() 17 | 18 | return Promise.resolve(todos) 19 | } 20 | 21 | addTodo({ todoTitle }: { todoTitle: string }): Promise { 22 | return this.getTodos().then((todos: Todo[]) => { 23 | const isTodoExists: boolean = 24 | todos.find((todo: Todo) => todo.title === todoTitle) !== 25 | undefined 26 | 27 | if (!isTodoExists) { 28 | const todo: Todo = { 29 | title: todoTitle, 30 | isDone: false, 31 | } 32 | 33 | todos.push(todo) 34 | 35 | this.setLocalTodos(todos) 36 | } 37 | 38 | return Promise.resolve(todos) 39 | }) 40 | } 41 | 42 | toggleCompleteTodo({ todoTitle }: { todoTitle: string }): Promise { 43 | return this.getTodos().then((todos: Todo[]) => { 44 | todos = [ 45 | ...todos.map((todo: Todo) => { 46 | return todo.title === todoTitle 47 | ? { 48 | ...todo, 49 | isDone: !todo.isDone, 50 | } 51 | : todo 52 | }), 53 | ] 54 | 55 | this.setLocalTodos(todos) 56 | 57 | return Promise.resolve(todos) 58 | }) 59 | } 60 | 61 | removeTodo({ todoTitle }: { todoTitle: string }): Promise { 62 | return this.getTodos().then((todos: Todo[]) => { 63 | todos = [...todos.filter((todo: Todo) => todo.title !== todoTitle)] 64 | 65 | this.setLocalTodos(todos) 66 | 67 | return Promise.resolve(todos) 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css" 2 | import type { AppProps } from "next/app" 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return 6 | } 7 | export default MyApp 8 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next" 2 | import Head from "next/head" 3 | import { TodoListContainer } from "@/modules/todos/application/todo-list/todo-list.container" 4 | 5 | const Home: NextPage = () => { 6 | return ( 7 | <> 8 | 9 | Clean architecture in front-end 10 | 11 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | 28 | export default Home 29 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Poppins:wght@200;400;600&display=swap"); 2 | 3 | html, 4 | body { 5 | padding: 0; 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 8 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 9 | background-color: #f5f5f5; 10 | color: #444; 11 | font-family: "Poppins", sans-serif; 12 | } 13 | 14 | a { 15 | color: inherit; 16 | text-decoration: none; 17 | } 18 | 19 | * { 20 | box-sizing: border-box; 21 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "baseUrl": ".", 17 | "paths": { 18 | "@/*": ["src/*"], 19 | "@/modules/*": ["src/modules/*"], 20 | "@/pages/*": ["src/pages/*"] 21 | } 22 | }, 23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 24 | "exclude": ["node_modules"] 25 | } 26 | --------------------------------------------------------------------------------