Todo App tutorial
In this guide, we’ll walk through the process of creating a simple Todo app.
This todo app is based on TodoMVC.
Source Code
You can get the source code for todo app from here.
git clone https://github.com/almin/almin.git
cd almin/examples/todomvc
npm install
npm start
# manually open
open http://localhost:8080/
What's learn from Todo app
- What is domain layer?
- POJO(Plain Old JavaScript Object)
- What is infra layer?
- Repository
- Which one should you use repository or store?
- Where is persistent data? - repository
- Where is data for view? - store
The purpose of Todo app
Todo app has these UseCases.
- AddTodoItem.js
- FilterTodoList.js
- RemoveAllCompletedItems.js
- RemoveTodoItem.js
- ToggleAllTodoItems.js
- ToggleTodoItem.js
- UpdateTodoItemTitle.js
and system UseCase
Story
We'll implement the following work flow and see it.
- Add Todo item
- Toggle Todo item's status
- ... Loop 1,2
- Filter Todo list and show only non-completed Todo items.
Previous knowledge
Previously, We learn flux pattern to create counter app.
In this guide, We learn basic CQRS(Command Query Responsibility Segregation) pattern using Almin.
CQRS splits that conceptual model into separate models - Command(Write) model and Query(Read) model.
In the figure, We called
- Command(Write) model "Write Stack" (Left side of the figure)
- "Write Stack" gets often complex.
- Because, it has business logic that is well-known as domain model.
- Query(Read) model "Read Stack" (Right side of the figure)
- "Read Stack" is similar concept of ViewModel and Store.
Domain model
Domain model is a object/class that has both behavior and data. In other word, domain model has property(data) and method(behavior).
Let's create domain model!
We want to create Todo app, then create ???
as domain model.
Yes, ???
is just TodoList
!
class TodoList {
// data and behavior
}
TodoList
TodoList
class has business logic and manage todo item.
Todo item is also domain model.
We are going to implement TodoItem
as value object.
TodoItem is value object
TodoItem
is a simple class that has these data
id
: identifiertitle
: todo titlecompleted
: true or false
"use strict";
const uuid = require("uuid");
export default class TodoItem {
constructor({ id, title, completed }) {
this.id = id || uuid();
this.title = title;
this.completed = completed;
}
updateItem(updated) {
return new TodoItem(Object.assign({}, this, updated));
}
}
TodoList is domain model
TodoList
class is domain model.
It should be plain JavaScript object,
We want to implement that adding TodoItem to TodoList.
"use strict";
export default class TodoList {
constructor() {
/**
* @type {TodoItem[]}
* @private
*/
this._items = [];
}
/**
* @param {TodoItem} todoItem
* @return {TodoItem}
*/
addItem(todoItem) {
this._items.push(todoItem);
return todoItem;
}
}
We can focus on business logic because domain model is a just plain JavaScript.
Where domain model is stored?
Now, we can create instances of domain model like this:
const todoList = new TodoList();
const todoItem = new TodoItem({ ... });
todoList.addTodo(todoItem);
But, How to store the instance of domain(TodoList
) as persistence.
We want to introduce Repository object. Repository stores domain model for perpetuation.
In the case, repository stores domain model into memory database.
Repository is simple class that has these feature:
- Can read/write memory database - memory database is a just
Map
object - Write domain instance into memory database
- Read domain instance from memory database
- When memory database is updated, emit "Change" event to subscriber
- Repository is just an EventEmitter
We want to store TodoList
instance to the repository.
As a result, We have created TodoListRepository
.
"use strict";
const EventEmitter = require("events");
const REPOSITORY_CHANGE = "REPOSITORY_CHANGE";
import TodoList from "../domain/TodoList/TodoList";
import MemoryDB from "./adpter/MemoryDB";
// Collection repository
export class TodoListRepository extends EventEmitter {
constructor(database = new MemoryDB()) {
super();
/**
* @type {MemoryDB}
*/
this._database = database;
}
/**
* @param id
* @private
*/
_get(id) {
// Domain.<id>
return this._database.get(`${TodoList.name}.${id}`);
}
find(todoList) {
return this._get(todoList.id);
}
/**
* @returns {TodoList|undefined}
*/
lastUsed() {
const todoList = this._database.get(`${TodoList.name}.lastUsed`);
if (todoList) {
return this._get(todoList.id);
}
}
/**
* @param {TodoList} todoList
*/
save(todoList) {
this._database.set(`${TodoList.name}.lastUsed`, todoList);
this._database.set(`${TodoList.name}.${todoList.id}`, todoList);
this.emit(REPOSITORY_CHANGE, todoList);
}
/**
* @param {TodoList} todoList
*/
remove(todoList) {
this._database.delete(`${TodoList.name}.${todoList.id}`);
this.emit(REPOSITORY_CHANGE);
}
onChange(handler) {
this.on(REPOSITORY_CHANGE, handler);
}
}
// singleton
export default new TodoListRepository();
Repository should be persistence object. In other words, create a repository instance as singleton.
Singleton? Does it may make dependency problems?
Of course, We can resolve that dependency issues by DIP(Dependency inversion principle).
DIP
Dependency inversion principle is a well-known layers pattern.
Domain should not depend on repository. Because, Domain doesn't know how to store itself. But, Repository can depend on domain.
When is domain model created?
It is just a System UseCase.
CreateDomainUseCase
is
- Actor: System
- Purpose: initialize domain model and save this to repository
Execute CreateDomainUseCase.js and initialize TodoList
domain and store the instance to repository.
We can put this to index.js
that is actual entry point of this application.
// create domain model and store to repository
appContext.useCase(CreateDomainUseCaseFactory.create()).execute().then(() => {
// mount app view
ReactDOM.render(<TodoApp appContext={appContext}/>, document.getElementById("todoapp"));
});
UseCase: AddTodoItem
Let's implement business login to TodoList
.
AddTodoItem UseCase does following steps:
- Get TodoList from repository
- Create new TodoItem
- Add New TodoItem to TodoList
- Save TodoList to repository
Execution steps:
execute(title) {
// Get todoList from repository
const todoList = this.todoListRepository.lastUsed();
// Create TodoItem
const todoItem = new TodoItem({title});
// Add TodoItem
todoList.addItem(todoItem);
// Save todoList to repository
this.todoListRepository.save(todoList);
}
All of AddTodoItem:
"use strict";
import { UseCase } from "almin";
import todoListRepository, { TodoListRepository } from "../infra/TodoListRepository";
import TodoItem from "../domain/TodoList/TodoItem";
export class AddTodoItemFactory {
static create() {
return new AddTodoItemUseCase({
todoListRepository
});
}
}
export class AddTodoItemUseCase extends UseCase {
/**
* @param {TodoListRepository} todoListRepository
*/
constructor({ todoListRepository }) {
super();
this.todoListRepository = todoListRepository;
}
execute(title) {
// Get todoList from repository
const todoList = this.todoListRepository.lastUsed();
// Create TodoItem
const todoItem = new TodoItem({ title });
// Add TodoItem
todoList.addItem(todoItem);
// Save todoList to repository
this.todoListRepository.save(todoList);
}
}
Factory of UseCase
You may notice about AddTodoItemFactory
.
AddTodoItemFactory
is not must, but it helps to write tests.
We can write test for AddTodoItem
UseCase.
"use strict";
const assert = require("power-assert");
import MemoryDB from "../../src/infra/adpter/MemoryDB";
import TodoList from "../../src/domain/TodoList/TodoList";
import { TodoListRepository } from "../../src/infra/TodoListRepository";
import { AddTodoItemUseCase } from "../../src/usecase/AddTodoItem";
describe("AddTodoItem", function() {
it("should add TodoItem with title", function() {
const mockTodoList = new TodoList();
// prepare
const todoListRepository = new TodoListRepository(new MemoryDB());
todoListRepository.save(mockTodoList);
// initialize
const useCase = new AddTodoItemUseCase({
todoListRepository
});
const titleOfAdding = "ADDING TODO";
// Then
todoListRepository.onChange(() => {
// re-get todoList
const storedTodoList = todoListRepository.find(mockTodoList);
const todoItem = storedTodoList.getAllTodoItems()[0];
assert.equal(todoItem.title, titleOfAdding);
});
// When
useCase.execute(titleOfAdding);
});
});
This pattern is well-known as Dependency injection(DI).
Conclusion of UseCase
AddTodoItem
UseCase does adding TodoItem and saving it.
Next, We want to render this result when a new TodoItem is added.
TodoStore
TodoStore is a almin's Store class.
In Counter app example, you already know about Store.
Almin's Store
- has State instance
- can receive the dispatched event from a UseCase.
- 🆕 can observe repository.
TodoStore observe repository
Repository is implemented as a singleton. You can observe the repository easily.
But We want to implement Store that should receive repository as a constructor arguments.
Why? It is a for testing. You already know this pattern as Dependency injection.
export default class TodoStore extends Store {
// TodoListRepository instance as arguments
// It is received from `AppStoreGroup` that is explained below.
constructor({todoListRepository}) {
// ...
}
}
Return to observe the repository.
You can use TodoListRepository#onChange
for observing repository.
- Observe change of repository
- When
todoListRepository
is changed, try to update state
"use strict";
import { Store } from "almin";
import TodoState, { FilterTypes } from "./TodoState";
export default class TodoStore extends Store {
/**
* @param {TodoListRepository} todoListRepository
*/
constructor({ todoListRepository }) {
super();
// Initial State
this.state = new TodoState({
items: [],
filterType: FilterTypes.ALL_TODOS
});
this.todoListRepository = todoListRepository;
// When `todoListRepository` is changed, try to update state
this.todoListRepository.onChange(todoList => {
this.setState(this.state.merge(todoList));
});
}
receivePayload(payload) {
this.setState(this.state.reduce(payload));
}
getState() {
return this.state;
}
}
In other way, you can implement updating state from changes of todoListRepository
.
Because, Store#receivePayload
is called in the Almin UseCase LifeCycle.
- onDispatch
- onError
- onDidExecuteEachUseCase
- onCompleteEachUseCase if needed
So, you can write following:
"use strict";
import { Store } from "almin";
import TodoState, { FilterTypes } from "./TodoState";
export default class TodoStore extends Store {
/**
* @param {TodoListRepository} todoListRepository
*/
constructor({ todoListRepository }) {
super();
// Initial State
this.state = new TodoState({
items: [],
filterType: FilterTypes.ALL_TODOS
});
this.todoListRepository = todoListRepository;
}
// Update state
receivePayload(payload) {
const todoList = this.todoListRepository.lastUsed();
if (!todoList) {
return;
}
const newState = this.state.merge(todoList).reduce(payload);
this.setState(newState);
}
// Read state
getState() {
return this.state;
}
}
And you can see the test for TodoStore.js
examples/todomvc/test/store/TodoStore-test.js
📝 Which is better?
Which is better place for updating state?
Repository#onChange
vs.Store#receivePayload
TL;DR: Case by Case, But we recommended to do update in the Store#receivePayload
.
It is difference that Repository#onChange
is outside of almin, Store#receivePayload
is inside of almin.
To update state should be done in almin life-cycle, because almin can optimize the updating process.
Repository#onChange
:
- Pros:
- The cost of reading from repository is minimal
- Cons:
- To update the state out of almin life-cycle
- Increase listen count of observing the repository in the store
Store#receivePayload
:
- Pros:
- To update the state in of almin life-cycle
- Can put state updating code at one part
- Cons:
- Store implicitly depended on almin
- It means that testing the store is a bit complex maybe.
- We recommended to test State instead of Store class.
- Store implicitly depended on almin
TodoState
TodoState
is a State class.
There are two ways of updating store:
- Receive
TodoList
object and return newTodoState
- Receive payload object and return new
TodoState
You can implement TodoState
like this.
export default class TodoState {
/**
* @param {TodoItem[]} items
* @param {string} filterType
*/
constructor({items, filterType} = {}) {
this.items = items;
this.filterType = filterType;
}
/**
* @param {TodoList} todoList
* @returns {TodoState}
*/
merge(todoList) {
const items = todoList.getAllTodoItems();
return new TodoState(Object.assign(this, {
items
}));
}
}
StoreGroup
Real application not only have a single state, but have many states.
Almin has StoreGroup
utility class that is a collection of stores.
AppStoreGroup
passes TodoListRepository
instance to TodoStore
.
"use strict";
import { StoreGroup } from "almin";
import TodoStore from "./TodoStore/TodoStore";
import todoListRepository from "../infra/TodoListRepository";
export default class AppStoreGroup {
static create() {
return new StoreGroup({
todoState: new TodoStore({ todoListRepository })
});
}
}
After that, you should initialize Almin's Context
with AppStoreGroup
.
// store
import AppStoreGroup from "./store/AppStoreGroup";
import {Context, Dispatcher} from "almin";
const dispatcher = new Dispatcher();
// context connect dispatch with stores
const appContext = new Context({
dispatcher,
store: AppStoreGroup.create()
});
StoreGroup -> View
Entry point of App's view observes TodoStore
via Almin's Context
.
The entry point is TodoApp.react.js
.
As a result, when TodoStore
is changed, TodoApp
is updated.
UseCase -> Domain -> Repository -> Store -> (New State) -> View
It is a Unidirectional data flow!
TodoMVC has other UseCases.
You can implement these in a similar way of AddTodoItem
or counter app
- FilterTodoList.js
- RemoveAllCompletedItems.js
- RemoveTodoItem.js
- ToggleAllTodoItems.js
- ToggleTodoItem.js
- UpdateTodoItemTitle.js
Conclusion
Almin provides two way for updating app's state.
- Fast path
- Dispatch events system
- It is well-known as Flux
- Long path
- Changes of Domain and Repository
- It is similar with server side architecture
Complex Web application needs to both.
For example, Animation must use the fast path. On the other hand, complex business logic should be written in domain models.
View -> UseCase -> (Thinking Point) -> Store
We can write code thinking :)
- [ ] Help to improve this document!