Giter VIP home page Giter VIP logo

ngrx-domains's Introduction

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'

@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)

ngrx-domains's People

Contributors

shlomiassaf avatar

Stargazers

Diego Vilar avatar Gabriel Schuster avatar John avatar hiepxanh avatar Alejandro Nereo Carballo avatar Vadym Parakonnyi avatar Malindu Warapitiya avatar Calebe Aires avatar Shawn Scott avatar  avatar Alexander Mikuta avatar  avatar louis avatar Steven Nance avatar  avatar  avatar Mateo Tibaquirá avatar Jonathan Gelin avatar  avatar Olivier Amblet avatar Isaac Mann avatar Hernán De Souza avatar dylan avatar Fabio avatar David Losert avatar

Watchers

James Cloos avatar Robert R. avatar  avatar  avatar Fabio avatar  avatar Alexandru Bereghici avatar  avatar Gabriel Schuster avatar

ngrx-domains's Issues

Is this library still alive ?

Is this library still alive ?

Angular version is 2.x, no code modification since long time, etc..

Too bad, I think it's a good idea !

Question on Effects and Guards ...

Greetings! This is a great addition to my toolbelt! Thank you for putting it together. I read your comment in the code stating that you left effects to be defined outside of domains. I wondered about that since if you 'registered' effects for each domain in a centralized way, the way you do for the other aspects of ngrx, then the root app module could simply iterate over the registered effect classes telling each of them to 'run'. This would prevent having to export them in the domains/index.ts file, breaking the encapsulation of the domain. Similarly, the 'registered' guards could be added to a collection to be referenced in the app module's providers section.

I think this would reduce the work needed to create and integrate a new domain into an existing app coded in this style and reduce the 'bookkeeping' needed in the main app module.

I am interested in your thoughts on this ...

Replay is not working

I have implemented same concept in my project. It worked perfectly fine. But replay is not working for my lazily loaded modules. Could you please help me what I need to do for that? I noticed, replay is not working in your example-app as well.

Questions on examples and weak typing

Thanks for putting this together. It really fits a need we have.

I noticed your examples do not enforce strong typing of reducer() arguments. The action is passed in as 'any'.

I'm also not clear on the switch on action.constructor instead of action.type.

Is this an oversight, a simplification, or a limitation?

I am also a bit confused by the README's Actions.ts and reducer.ts which define/consume actions and action types differently from your source examples. I'm fairly new to typescript and especially extending types, so I apologize if this is an obvious question.

Thanks.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.