We use redux and redux-saga for application state management. Redux can be seen as CQRS / ES for frontend architecture. At least the concepts are very similar. However, the standard redux concept does not distinguish between commands and events. Both are just redux actions. Since we use CQRS / ES in the backend and see a lot of value in distinguishing between message types, we've set up a few additional conventions on top of redux to narrow frontend and backend architecture.
The app is organized in modules. Each module follows the same directory structure:
module root
|_ actions
|_ api.ts -- methods to communicate with backend
|_ commands.ts -- command factories
|_ constants.ts -- action types
|_ events.ts -- event factories
|_ queries.ts -- query factories
|_ index.tsx -- exports: Api, Type, Command, Event, Query
|_ components -- react components ⚙️ use ./phpstorm_templates.md#react-component
|_ model
|_ index.ts -- exports: all [AggregateType]Model
|_ [AggregateType].ts -- Types and record class for aggregate type ⚙️ use ./phpstorm_templates.md#immutable-aggregate
|_ ...
|_ reducers
|_ index.ts -- exports: module reducer
|_ apply[AggregateType]Events.ts -- Aggregate type specific reducer ⚙️ use ./phpstorm_templates.md#aggregate-events-reducer
|_ sagas
|_ index.ts -- exports: all sagas
|_ [commandName].ts ⚙ use ./phpstorm_templates.md#redux-saga-command-handler
|_ selectors
|_ index.ts -- exports: all selectors
|_ select[AggregateType].ts ⚙ use .
|_ index.ts -- exports: MODULE (module name constant),
Action (* from /actions),
Model (* from /model),
Reducer (* from /reducers),
Saga (* from /sagas),
Selector (* from /selectors)
Commands are typesafe-actions
and follow an imperative naming convention for the action type.
When choosing a name you can think of the "Tell, don't ask" principle. Furthermore, each command
should be prefixed with the name of the context it belongs to. Start the context name with two @
signs:
const COMMAND_ACTION_TYPE = '@@[ContextName]/[CommandName]'
export const ADD_TEAM = '@@InspectioTeams/AddTeam';
export const RENAME_TEAM = '@@InspectioTeams/RenameTeam';
export const CHANGE_TEAM_DESCRIPTION = '@@InspectioTeams/ChangeTeamDescription';
export const INVITE_USER_TO_TEAM = '@@InspectioTeams/InviteUserToTeam';
export const REMOVE_MEMBER_FROM_TEAM = '@@InspectioTeams/RemoveMemberFromTeam';
export const DELETE_TEAM = '@@InspectioTeams/DeleteTeam';
Commands are dispatched by react components, sagas or background processes (like a web worker). Only redux sagas should handle commands. A saga should load the responsible aggregate from the redux store (using a selector) and call a use case specific method on the aggregate to get back a modified version of the aggregate.
import {call, fork, put, select, take} from 'redux-saga/effects';
import {ActionType} from "typesafe-actions";
import {ResponseType} from "../../api/util";
import {Action as NotifyAction} from "../../NotificationSystem";
import {Action} from "../index";
import {TeamModel} from "../model";
import {makeTeamSelector} from "../selectors/teamList";
type Command = ActionType<typeof Action.Command.changeTeamDescription>
function* handleChangeTeamDescription(command: Command) {
const orgTeam: TeamModel.Team = yield select(makeTeamSelector(command.payload.teamId));
const changedTeam = orgTeam.changeDescription(command.payload.newDescription);
yield put(Action.Event.teamDescriptionChanged(changedTeam));
const {response, error}: ResponseType = yield call(Action.Api.changeTeamDescription, command.payload.teamId, command.payload.newDescription);
if(error) {
yield put(NotifyAction.Command.error('Request Error', 'Could not change team description.'));
yield put(Action.Event.teamDescriptionChanged(orgTeam));
}
if(response) {
yield put(NotifyAction.Command.info('Team Description Changed', 'Description saved successfully'));
}
}
export function* changeTeamDescription() {
while (true) {
const command: Command = yield take([
Action.Type.CHANGE_TEAM_DESCRIPTION
]);
yield fork(handleChangeTeamDescription, command);
}
}
Just like commands, events are typesafe-actions
, too. But they should be named in past tense.
const EVENT_ACTION_TYPE = '@@[ContextName]/[EventName]';
export const TEAM_ADDED = '@@InspectioTeams/TeamAdded';
export const TEAM_RENAMED = '@@InspectioTeams/TeamRenamed';
export const TEAM_DESCRIPTION_CHANGED = '@@InspectioTeams/TeamDescriptionChanged';
export const TEAMS_FETCHED = '@@InspectioTeams/TeamsFetched';
export const MEMBER_REMOVED_FROM_TEAM = '@@InspectioTeams/MemberRemovedFromTeam';
export const TEAM_DELETED = '@@InspectioTeams/TeamDeleted';
Event payload should either be a new or modified aggregate or a list of aggregates. Sagas should dispatch events as the last step after command handling.
Note: When a react component or a saga has loaded aggregates from the server, they can dispatch a {Aggregate(s)}Loaded event to let the responsible reducer add or replace the aggregate(s) in the redux store.
function* handleChangeTeamDescription(command: Command) {
const orgTeam: TeamModel.Team = yield select(makeTeamSelector(command.payload.teamId));
const changedTeam = orgTeam.changeDescription(command.payload.newDescription);
yield put(Action.Event.teamDescriptionChanged(changedTeam));
// ...
}
Events and ONLY events are handled by reducers. The core
package includes helper functions for reducers to ease
redux store updates. Check the example below for details:
import {Map} from 'immutable';
import {Reducer} from 'redux';
import {
addAggregate,
removeAggregate,
updateAggregate,
upsertAggregates
} from "../../core/reducer/applyAggregateChanged";
import {
MEMBER_REMOVED_FROM_TEAM,
TEAM_ADDED,
TEAM_DELETED,
TEAM_DESCRIPTION_CHANGED,
TEAM_RENAMED,
TEAMS_FETCHED
} from "../actions/constants";
import {TeamModel} from '../model';
import {InspectioTeamEvent} from './index';
export interface TeamsState extends Map<string, TeamModel.Team> {
}
export const initialState: TeamsState = Map<string, TeamModel.Team>();
const reducer: Reducer<TeamsState, InspectioTeamEvent> = (state: TeamsState = initialState, event: InspectioTeamEvent): TeamsState => {
switch (event.type) {
case TEAM_ADDED:
return addAggregate(state, event.payload);
case TEAM_RENAMED:
case TEAM_DESCRIPTION_CHANGED:
case MEMBER_REMOVED_FROM_TEAM:
return updateAggregate(state, event.payload);
case TEAMS_FETCHED:
return upsertAggregates(state, event.payload);
case TEAM_DELETED:
return removeAggregate(state, event.payload);
default:
return state;
}
};
export { reducer as saveTeamReducer };
This mechanism simplifies reducer logic a lot. Aggregates control how state changes. They encapsulate behavior. Reducers are dumb.
Since aggregates should extend immutable/record
state changes still happen without side effects and the redux store stays immutable.
Aggregates are immutable/record
s implementing core/model/Aggregate
. They should provide use case specific methods to change their state so
that sagas can call them and get back a modified version of the aggregate.
import {List, Record} from 'immutable';
import * as uuid from 'uuid';
import {Aggregate, AggregateType} from "../../core/model/Aggregate";
import {UserId} from "../../User/model/UserInfo";
export type TeamId = string;
export type TeamName = string;
export type TeamDescription = string;
export type TeamMemberQuota = number;
export const AGGREGATE_TYPE = 'Team';
// ...
export class Team extends Record(defaultTeamProps) implements TeamProps, Aggregate {
public constructor(data: TeamProps) {
if(data.uid === '') {
data.uid = uuid.v4()
}
super(data);
}
public rename(newName: TeamName): Team {
return this.set('name', newName);
}
public changeDescription(newDescription: TeamDescription): Team {
return this.set('description', newDescription);
}
public removeMember(userId: UserId): Team {
const state = this.set('members', this.members.filter(member => member !== userId));
return state.set('admins', state.admins.filter(admin => admin !== userId));
}
}
Please check out PHPStorm Templates