ngrx-domains

A Global, Type friendly, registry for your **NGRX** logic

Usage no npm install needed!

<script type="module">
  import ngrxDomains from 'https://cdn.skypack.dev/ngrx-domains';
</script>

README

ngrx-domains

Get your state together - A plugin oriented Global registry for your NGRX logic

TL;DR

Use a global registry to create encapsulated redux logic modules (domains).

Each domain is a plugin, no hard dependencies.

Supports lazy domains (lazy injection of a reducer)

Create a dependency free global redux role objects, as you go, per module:

  • State
  • Actions
  • Models
    Classes or Interfaces exposed to the app by a specific domain.
  • Queries
    Observables for a specific property on the state, or a transformation of it

Access redux role objects from a single import:

import { Actions, State, Model, Root, Queries } from 'ngrx-domains/State'

@Component({
  selector: 'my-cmp'
  /* metadata here */
})
export class MyCmpComponent {
  user$: Observable<Model.User>; // access types published by the domain 
  userName$: Observable<string>; 
  
  constructor(private store: Store<State>) {
    // State object type contains type data published by all domains.
  
  
    // root queries (table level)
    this.user$ = store.select(Root.user)    
    
    // namespaced queries defined by the domain:
    this.userName$ = store.select(Queries.user.name)    
  }


  changeName(name: string) {
  // namespaced actions defined by the domain, full auto-complete:
    this.store.dispatch(Actions.user.changeName(name));            
  }

}

Demo

See the src folder, containing a ported version of @ngrx/example-app using ngrx-domains

The ngrx-domains version of @ngrx/example-app contains a lazy loading demo where a domain is registered with a lazy-loaded module (stats pages).

See the demo site.

Alpha release

This library is in an early stage of development, expect some changes.

The Actions objects will probably change since @ngrx/effects has an Actions type.

Background

Redux is cool, super cool, but its hard to manage. Having all that boilerplate and strict discipline, that's tough!
Working with NGRX I found 2 painful issues that this library try to solve:

  • Typescript: global State type (modularize, lazy load)
  • File Structure

State type

Our store represents a shared global state object. It's an empty object.
Reducers populate the global state, each property on the global state is a child state managed by a reducer.

We can think of the store as a database where each property is a table, hence a reducer is a table.

Each reducer defines it own state interface internally, we don't have a typed global state.
Every time we use the store we get a portion of the global state type.

constructor(store: Store<State>) { }

The generic State param is the interface we import from one of the reducers.

On a large project importing a state from a reducer somewhere in the project comes with some pain.

We can easily combine all states together so we get a complete global state type:

import * as fromBooks from './books';
import * as fromCollection from './collection';
import * as fromLayout from './layout';

export interface State {
  books: fromBooks.State;
  collection: fromCollection.State;
  layout: fromLayout.State;
}

This means we need to import every reducer, extra work but most important creates a dependency.
It works fine on small projects using the By Redux role structure but it does not scale.

A Dependency makes it hard to modularize, tree shake and lazy load with NGRX.

File structure

The file structure and organization of our code is crucial for the maintainability of the application.
As the project grows it's getting difficult to structure the code so developers can easily understand the context, access and use it.

There are many ways for structuring your code, the 2 most popular are:

By feature

Each application feature contains it own set or redux roles.

├── project-root/
│   ├── book
│   │   ├── actions.ts
│   │   ├── effects.ts
│   │   ├── index.ts
│   │   ├── reducer.ts
│   ├── collection
│   │   ├── actions.ts
│   │   ├── effects.ts
│   │   ├── index.ts
│   │   ├── reducer.ts
│   ├── layout
│   │   ├── actions.ts
│   │   ├── effects.ts
│   │   ├── index.ts
│   │   ├── reducer.ts
pros:

Scales: Changes are scoped into a single module, one place for all the redux logic and domain logic.

cons:

Dependent: Accessing features is done by importing it module using hard coded relative path reference.
Hard to access: Sometimes features are nested inside other modules

By Redux role

Each role (Actions, Reducers) in the Redux eco-system (and Effects in ngrx) has a module, project wide.

├── project-root/
│   ├── actions
│   │   ├── book.ts
│   │   ├── collection.ts
│   │   ├── layout.ts
│   ├── effects
│   │   ├── book.ts
│   │   ├── collection.ts
│   ├── reducers
│   │   ├── book.ts
│   │   ├── collection.ts
│   │   ├── index.ts
│   │   ├── layout.ts

Some roles acts as classic modules with index.ts (reducers)
some acts as containers where each internal file is a module (actions, effects)

pros:

Easy access: This structure allows easy access when importing from other location in our app.

cons:

Does not scale: When adding or changing features some groups of objects tend to change together for example, when we change the reducers/book.ts we will probably change actions/book.ts and effects/books.ts.

Enter domains

A Domain is a namespaced encapsulated feature that you can access easily from anywhere in your app without directly referencing it.

Each Domain is a redux logic unit that is responsible for:

  • publish itself in the registry
  • publish it's type information
  • publish interaction methods (Actions)
  • manage all logic of the domain (reducers)
  • publish domain queries (optional)
  • publish domain models (optional)

A Domain can also encapsulate it own @Effect services, they can be created outside of the domain. Since importing domain objects is easy they can live outside the domain. That's a choice of preference, @Effect has domain logic so it should be inside but its also an @Injectable...

A Domain is like a plugin, it attaches itself to the registry. The registry does not know about the plugin/domain.

Going back to the Database metaphor with a tint of SQL:
A domain is a managed table that comes with:

  • Typed table schema (state)
  • Predefined CRUD Functions / STP (actions, reducers)
  • Predefined observed Views (queries)

How does it work?

ngrx-domains works on 2 levels, runtime and design time.
It uses TypeScripts modules and namespaces to extend types, similar to the way rxjs 5 allows extending Observables

Runtime:

Dynamically adding objects (actions, reducers, queries, models) to a global registry.

Design time:

Being able to reflect the dynamic additions as "concrete" type information.
Remember that the global registry is empty, actions, state, queries, etc... are all empty object with no type information.
Adding type information requires a small amount boilerplate that helps TypeScript know about the structure. A lot of this boilerplate you would have done anyway.

The boilerplate represents Type information, as so it has no effect on the javascript output, i.e it has no footprint on the compiled code emitted by TypeScript.

Lazy domains

Angular can lazy load modules, infect its a must for all medium sized apps and up. It is obvious that we want to define domains inside modules and load them only when required. Since ngrx-domains is plugin oriented this is quite easy.

ngrx-domains has an observable that emits whenever a new domain is registered, we can subscribe to it and re-create the reducer tree every time a new domain is added.

import { combineReducers } from '@ngrx/store';
import { getReducers, tableCreated$ } from 'ngrx-domains';
let reducer;


tableCreated$.subscribe( (table: string) => {
  console.log('Reducer updated');
  // ngrx-domains returns a reducers map, you can use combineReducers or any other implementation...
  reducer = combineReducers(getReducers());
});

// rootReducer exposed as the actual root reducer, it will never change.
// the inner reducer does.
export function rootReducer(state: any, action: any) {
  return reducer(state, action);
}

tableCreated$ is hooked to a ReplaySubject.

Example

In this example we're creating a domain called simpleUser, we will separate redux roles by file but the whole simpleUser feature will be in one module.
We have a model, 1 action (changing the name) and a query to get the logged in state.

├── project-root/
│   ├── simpleUser
│   │   ├── Actions.ts
│   │   ├── index.ts
│   │   ├── Model.ts
│   │   ├── Queries.ts
│   │   ├── reducer.ts
│   │   ├── State.ts

This structure is just for demonstration, you can follow any convention you like.

File: Model.ts

import { register } from 'ngrx-domains';

namespace UserModels {
  export class SimpleUser {
    constructor(public name: string) {}
  }
}


register(UserModels.SimpleUser);

declare module 'ngrx-domains' {
  export namespace Model {
    export const SimpleUser: typeof UserModels.SimpleUser;
    export type SimpleUser = UserModels.SimpleUser;
  }
}

File: Actions.ts

import { Action } from '@ngrx/store';
import { Actions } from 'ngrx-domains';

export class UserActions {
  static CHANGE_NAME = '[SimpleUser] Change User Name';
  changeName(name: string): Action {
    return {
      type: UserActions.CHANGE_NAME,
      payload: name
    };
  }
}

// this will fail type check if the module declaration below is not set
Actions.simpleUser = new UserActions();

// adding type information
declare module 'ngrx-domains' {
  interface Actions {
    simpleUser: UserActions;
  }
}

File: State.ts

import { State, Model } from 'ngrx-domains';
const { SimpleUser } = Model;

// This is our initial state
State.simpleUser = {
  user: new SimpleUser('John'),
  loggedIn: false
};

// type information
declare module 'ngrx-domains' {
  export interface SimpleUserState {
    user: Model.SimpleUser;
    loggedIn: boolean;
  }

  interface State {
    simpleUser: SimpleUserState
  }
}

File: Queries.ts

import { Query } from 'ngrx-domains';
import { SimpleUserState, Queries, Root, combineRootFactory } from 'ngrx-domains/State';

export interface SimpleQueries {
  // IN: State.simpleUser -> OUT: State.simpleUser.loggedIn
  loggedIn: Query<boolean>;
}

const fromRoot = combineRootFactory<SimpleUserState>('simpleUser');


Queries.simpleUser = {
  loggedIn: fromRoot( state => state.loggedIn )
};

declare module 'ngrx-domains' {
  interface Root {
    simpleUser: Query<SimpleUserState>;
  }

  interface Queries {
    simpleUser: SimpleQueries;
  }
}

File: reducer.ts

import { Action } from '@ngrx/store';
import { State, SimpleUserState, Model, UserActions } from 'ngrx-domains';

const { SimpleUser } = Model;

export function reducer(state: SimpleUserState, action: Action): SimpleUserState {
  if (!state) state = State.simpleUser; // State.simpleUser is typed

  switch (action.type) {
    case UserActions.CHANGE_NAME: {
      return Object.assign({}, state, {
        user: new SimpleUser(action.payload)
      });
    }

    default: {
      return state;
    }
  }
}

File: index.ts

import { createDomain } from 'ngrx-domains';
import './Model';
import './State';
import './Actions';
import './Queries';

import { reducer } from './reducer';

// publish the reducer
createDomain('simpleUser', reducer);

Development

lib - Directory holding the ngrx-domains library code in TS. src - A demo app until units tests...

The demo apps should consume a compiled version of lib, this is why there is a compilation process for the lib separate from the demo app.

npm run start will fire lib compilation + watch and demo app server via angular-cli (ng serve).

lib compiles to node_modules/ngrx-domains, src is a module directory on the demo app so any import {} from 'ngrx-domains' will work.

TODO / DESIGN / THOUGHTS:

  • Use metadata via decorators in addition to createDomain?
  • remove dependency on ngrx/store (only using ActionReducer interface)
  • remove dependency on reselect (allow user to provide the selector factory)