Skip to content

Instantly share code, notes, and snippets.

@codeliner
Last active August 4, 2022 09:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save codeliner/8751eb0ec86b63a22d81b8707738ee5b to your computer and use it in GitHub Desktop.
Save codeliner/8751eb0ec86b63a22d81b8707738ee5b to your computer and use it in GitHub Desktop.
CQRS / ES Redux Addition for prooph board FE development

CQRS / ES Redux Addition

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.

Modules

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

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]'

Command Examples

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';

Command Handling

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.

Handling Example

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);
    }
}

Events

Just like commands, events are typesafe-actions, too. But they should be named in past tense.

const EVENT_ACTION_TYPE = '@@[ContextName]/[EventName]';

Event Examples

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 Dispatching

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));
    
    // ...
}

Event Handling

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

Aggregates are immutable/records 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));
    }
}

PHPStorm Templates

Please check out PHPStorm Templates

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment