Hierarchical dependency injection in React and MobX State Tree as a domain model

    I had a chance somehow after several projects on React to work on the application under Angular 2. Frankly speaking, I was not impressed. But one moment I remember - managing the logic and state of the application using Dependency Injection. And I wondered, is it convenient to manage state in React using DDD, a multi-layered architecture, and dependency injection?


    If you are interested in how to do this, and most importantly, why - welcome under the cat!


    To be honest, even on the backend DI is rarely used to its fullest. Is that in really large applications. And in small and medium, even with DI, each interface usually has only one implementation. But dependency injection still has its advantages:


    • The code is better structured, and the interfaces act as explicit contracts.
    • Simplifies the creation of stubs in unit tests.

    But modern testing libraries for JS, such as Jest , allow you to write mocks simply on the basis of the modular ES6 system. So here we don’t get much profit from DI.


    There remains a second point - the management of the scope and lifetime of objects. On the server, the lifetime is usually tied to the entire application (Singleton), or to the request. And on the client, the main unit of the code is the component. To him we will be attached.


    If we need to use the state at the application level, the easiest way is to create a variable at the level of the ES6 module and import it where necessary. And if the state is needed only inside the component, we will simply place it in this.state. For everything else there is Context. But Context- too low level:


    • We cannot use the context outside the React component tree. For example, in the business logic layer.
    • We cannot use more than one context in Class.contextType. To determine the dependence on several different services, we will have to build a "pyramid of horror" in a new way:



    The new Hook useContext()slightly corrects the situation for functional components. But we can not get rid of the set <Context.Provider>. Until we turn our context into a Service Locator, and its parent component into a Composition Root. But here it is not far to DI, so let's get started!


    You can skip this part and go directly to the description of the architecture.

    DI implementation


    First we need React Context:


    exportconst InjectorContext= React.createContext(null);

    Since React uses the component constructor for its needs, we will use Property Injection. To do this, we define the decorator @inject, which:


    • sets a property Class.contextType,
    • gets type of dependency
    • finds the object Injectorand resolves the dependency.

    inject.js
    import"reflect-metadata";
    exportfunctioninject(target, key) {
      // задаем static cotextType
      target.constructor.contextType = InjectorContext;
      // получаем тип зависимостиconst type = Reflect.getMetadata("design:type", target, key);
      // определяем propertyObject.defineProperty(target, key, {
        configurable: true,
        enumerable: true,
        get() {
          // получаем Injector из иерархии компонентов и разрешаем зависимостьconst instance = getInstance(getInjector(this), type);
          Object.defineProperty(this, key, {
            enumerable: true,
            writable: true,
            value: instance
          });
          return instance;
        },
        // settet для присваивания в обход Dependency Injection
        set(instance) {
          Object.defineProperty(this, key, {
            enumerable: true,
            writable: true,
            value: instance
          });
        }
      });
    }

    Now we can define dependencies between arbitrary classes:


    import { inject } from"react-ioc";
    classFooService{}
    classBarService{
      @inject foo: FooService;
    }
    classMyComponentextendsReact.Component{
      @inject foo: FooService;
      @inject bar: BarService;
    }

    For those who do not accept decorators, we define a function inject()with the following signature:


    type Constructor<T> = new (...args: any[]) => T;
    functioninject<T>(target: Object, type: Constructor<T> | Function): T;

    inject.js
    exportfunctioninject(target, keyOrType) {
      if (isFunction(keyOrType)) {
        return getInstance(getInjector(target), keyOrType);
      }
      // ...
    }

    This will allow you to define dependencies explicitly:


    classFooService{}
    classBarService{
      foo = inject(this, FooService);
    }
    classMyComponentextendsReact.Component{
      foo = inject(this, FooService);
      bar = inject(this, BarService);
      // указываем явноstatic contextType = InjectorContext;
    }

    What about the functional components? For them we can implement HookuseInstance()


    hooks.js
    import { useRef, useContext } from"react";
    exportfunctionuseInstance(type) {
      const ref = useRef(null);
      const injector = useContext(InjectorContext);
      return ref.current || (ref.current = getInstance(injector, type));
    }

    import { useInstance } from"react-ioc";
    const MyComponent = props => {
      const foo = useInstance(FooService);
      const bar = useInstance(BarService);
      return<div />;
    }

    Now we define how ours might look Injector, how to find it, and how to resolve dependencies. The injector should contain a link to the parent, an object cache for already resolved dependencies, and a dictionary of rules for those not yet allowed.


    injector.js
    type Binding = (injector: Injector) =>Object;
    export abstract classInjectorextendsReact.Component{
      // ссылка на вышестоящий Injector
      _parent?: Injector;
      // настройки разрешения зависимостей
      _bindingMap: Map<Function, Binding>;
      // кэш для уже созданных экземпляров
      _instanceMap: Map<Function, Object>;
    }

    For React components, it Injectoris available through the field this.context, and for dependency classes, we can temporarily put it Injectorin a global variable. To speed up the search for the injector for each class, we will cache the link Injectorin a hidden field.


    injector.js
    exportconst INJECTOR =
      typeofSymbol === "function" ? Symbol() : "__injector__";
    let currentInjector = null;
    exportfunctiongetInjector(target) {
      let injector = target[INJECTOR];
      if (injector) {
        return injector;
      }
      injector = currentInjector || target.context;
      if (injector instanceof Injector) {
        target[INJECTOR] = injector;
        return injector;
      }
      returnnull;
    }

    To find a specific binding rule, we need to go up the injector tree using the function getInstance()


    injector.js
    exportfunctiongetInstance(injector, type) {
      while (injector) {
        let instance = injector._instanceMap.get(type);
        if (instance !== undefined) {
          return instance;
        }
        const binding = injector._bindingMap.get(type);
        if (binding) {
          const prevInjector = currentInjector;
          currentInjector = injector;
          try {
            instance = binding(injector);
          } finally {
            currentInjector = prevInjector;
          }
          injector._instanceMap.set(type, instance);
          return instance;
        }
        injector = injector._parent;
      }
      returnundefined;
    }

    We turn finally to the registration of dependencies. To do this, we need a HOC provider()that accepts an array of dependency bindings for their implementations, and registers a new Injectorone throughInjectorContext.Provider


    provider.js
    exportconst provider = (...definitions) => Wrapped => {
      const bindingMap = newMap();
      addBindings(bindingMap, definitions);
      returnclassProviderextendsInjector{
        _parent = this.context;
        _bindingMap = bindingMap;
        _instanceMap = newMap();
        render() {
          return (
            <InjectorContext.Provider value={this}>
              <Wrapped {...this.props} />
            </InjectorContext.Provider>
          );
        }
        static contextType = InjectorContext;
        static register(...definitions) {
          addBindings(bindingMap, definitions);
        }
      };
    };

    Also, a set of bindings functions that implement various dependency instance creation strategies.


    bindings.js
    exportconst toClass = constructor =>
      asBinding(injector => {
        const instance = newconstructor();
        if (!instance[INJECTOR]) {
          instance[INJECTOR] = injector;
        }
        return instance;
      });
    exportconst toFactory = (depsOrFactory, factory) =>
      asBinding(
        factory
          ? injector =>
              factory(...depsOrFactory.map(type => getInstance(injector, type)))
          : depsOrFactory
      );
    exportconst toExisting = type =>
      asBinding(injector => getInstance(injector, type));
    exportconst toValue = value => asBinding(() => value);
    const IS_BINDING = typeofSymbol === "function" ? Symbol() : "__binding__";
    functionasBinding(binding) {
      binding[IS_BINDING] = true;
      return binding;
    }
    exportfunctionaddBindings(bindingMap, definitions) {
      definitions.forEach(definition => {
        let token, binding;
        if (Array.isArray(definition)) {
          [token, binding = token] = definition;
        } else {
          token = binding = definition;
        }
        bindingMap.set(token, binding[IS_BINDING] ? binding : toClass(binding));
      });
    }

    Now we can register dependency bindings at the level of an arbitrary component as a set of pairs [<Интерфейс>, <Реализация>].


    import { provider, toClass, toValue, toFactory, toExisting } from"react-ioc";
    @provider(
      // привязка к классу
      [FirstService, toClass(FirstServiceImpl)],
      // привязка к статическому значению
      [SecondService, toValue(new SecondServiceImpl())],
      // привязка к фабрике
      [ThirdService, toFactory(
        [FirstService, SecondService],
        (first, second) => ThirdServiceFactory.create(first, second)
      )],
      // привязка к уже зарегистрированному типу
      [FourthService, toExisting(FirstService)]
    )
    classMyComponentextendsReact.Component{
      // ...
    }

    Or in abbreviated form for classes:


    @provider(
      // [FirstService, toClass(FirstService)]
      FirstService,
      // [SecondService, toClass(SecondServiceImpl)]
      [SecondService, SecondServiceImpl]
    )
    classMyComponentextendsReact.Component{
      // ...
    }

    Since the lifetime of the service is determined by the component provider in which it is registered, for each service we can determine the cleaning method .dispose(). In it, we can unsubscribe from some events, close sockets, etc. When you remove a provider from the DOM, it will call .dispose()on all the services it creates.


    provider.js
    exportconst provider = (...definitions) => Wrapped => {
      // ...returnclassProviderextendsInjector{
        // ...
        componentWillUnmount() {
          this._instanceMap.forEach(instance => {
            if (isObject(instance) && isFunction(instance.dispose)) {
              instance.dispose();
            }
          });
        }
        // ...
      };
    };

    To separate the code and lazy loading, we may need to invert the method of registering services with providers. The decorator will help us with this.@registerIn()


    provider.js
    exportconst registrationQueue = [];
    exportconst registerIn = (getProvider, binding) =>constructor => {
      registrationQueue.push(() => {
        getProvider().register(binding ? [constructor, binding] : constructor);
      });
      return constructor;
    };

    injector.js
    exportfunctiongetInstance(injector, type) {
      if (registrationQueue.length > 0) {
        registrationQueue.forEach(registration => {
          registration();
        });
        registrationQueue.length = 0;
      }
      while (injector) {
      // ...
    }

    import { registerIn } from"react-ioc";
    import { HomePage } from"../components/HomePage";
    @registerIn(() => HomePage)
    classMyLazyLoadedService{}


    So, for 150 lines and 1 KB of code, you can implement an almost complete hierarchical DI-container .


    Application architecture


    Finally, let's get to the main point - how to organize the architecture of the application. There are three possible options depending on the size of the application, the complexity of the subject area and our laziness.


    1. The Ugly


    We have the same Virtual DOM, which means it should be fast. At least under this sauce React was served at the dawn of a career. Therefore, just remember the link to the root component (for example, using a decorator @observer). And we will call on it .forceUpdate()after each action affecting common services (for example, using a decorator @action)


    observer.js
    exportfunctionobserver(Wrapped) {
      returnclassObserverextendsReact.Component{
        componentDidMount() {
          observerRef = this;
        }
        componentWillUnmount() {
          observerRef = null;
        }
        render() {
          return<Wrapped {...this.props} />;
        }
      }
    }
    let observerRef = null;

    action.js
    exportfunctionaction(_target, _key, descriptor) {
      const method = descriptor.value;
      descriptor.value = function() {
        let result;
        runningCount++;
        try {
          result = method.apply(this, arguments);
        } finally {
          runningCount--;
        }
        if (runningCount === 0 && observerRef) {
          observerRef.forceUpdate();
        }
        return result;
      };
    }
    let runningCount = 0;

    classUserService{
      @action doSomething() {}
    }
    classMyComponentextendsReact.Component{
      @inject userService: UserService;
    }
    @provider(UserService)
    @observer
    classAppextendsReact.Component{}

    It will even work. But ... You understand :-)


    2. The Bad


    We are not satisfied with rendering everything for every sneeze. But we still want to usenearlyordinary objects and arrays for storing state. Let's take MobX !


    We get several data stores with standard actions:


    import { observable, action } from"mobx";
    exportclassUserStore{
      byId = observable.map<number, User>();
      @action
      add(user: User) {
        this.byId.set(user.id, user);
      }
      // ...
    }
    exportclassPostStore{
      // ...
    }

    Business logic, I / O, etc. are placed in the services layer:


    import { action } from"mobx";
    import { inject } from"react-ioc";
    exportclassAccountService{
      @inject userStore userStore;
      @action
      updateUserInfo(userInfo: Partial<User>) {
        const user = this.userStore.byId.get(userInfo.id);
        Object.assign(user, userInfo);
      }
    }

    And we distribute them into components:


    import { observer } from"mobx-react";
    import { provider, inject } from"react-ioc";
    @provider(UserStore, PostStore)
    classAppextendsReact.Component{}
    @provider(AccountService)
    @observer
    classAccountPageextendsReact.Component{}
    @observer
    classUserFormextendsReact.Component{
      @inject accountService: AccountService;
    }

    The same for functional components and without decorators
    import { action } from"mobx";
    import { inject } from"react-ioc";
    exportclassAccountService{
      userStore = inject(this, UserStore);
      updateUserInfo = action((userInfo: Partial<User>) => {
        const user = this.userStore.byId.get(userInfo.id);
        Object.assign(user, userInfo);
      });
    }

    import { observer } from"mobx-react-lite";
    import { provider, useInstance } from"react-ioc";
    const App = provider(UserStore, PostStore)(props => {
      // ...
    });
    const AccountPage = provider(AccountService)(observer(props => {
      // ...
    }));
    const UserFrom = observer(props => {
      const accountService = useInstance(AccountService);
      // ...
    });

    It turns out a classic three-tier architecture.


    3. The Good


    Sometimes the domain becomes so complex that it is already inconvenient to work with it using simple objects (or an anemic model in terms of DDD). This is especially noticeable when the data has a relational structure with many links. In such cases, the MobX State Tree library comes to the rescue , allowing you to apply the principles of the Domain-Driven Design in the frontend application architecture.


    Designing a model begins with a description of the types:


    // models/Post.tsimport { types as t, Instance } from"mobx-state-tree";
    exportconst Post = t
      .model("Post", {
        id: t.identifier,
        title: t.string,
        body: t.string,
        date: t.Date,
        rating: t.number,
        author: t.reference(User),
        comments: t.array(t.reference(Comment))
      })
      .actions(self => ({
        voteUp() {
          self.rating++;
        },
        voteDown() {
          self.rating--;
        },
        addComment(comment: Comment) {
          self.comments.push(comment);
        }
      }));
    export type Post = Instance<typeof Post>;

    models / User.ts
    import { types as t, Instance } from"mobx-state-tree";
    exportconst User = t.model("User", {
      id: t.identifier,
      name: t.string
    });
    export type User = Instance<typeof User>;

    models / Comment.ts
    import { types as t, Instance } from"mobx-state-tree";
    import { User } from"./User";
    exportconst Comment = t
      .model("Comment", {
        id: t.identifier,
        text: t.string,
        date: t.Date,
        rating: t.number,
        author: t.reference(User)
      })
      .actions(self => ({
        voteUp() {
          self.rating++;
        },
        voteDown() {
          self.rating--;
        }
      }));
    export type Comment = Instance<typeof Comment>;

    And the type of data storage:


    // models/index.tsimport { types as t } from"mobx-state-tree";
    export { User, Post, Comment };
    exportdefault t.model({
      users: t.map(User),
      posts: t.map(Post),
      comments: t.map(Comment)
    });

    Entity types contain the state of the domain model and basic operations with it. More complex scenarios, including I / O, are implemented in the services layer.


    services / DataContext.ts
    import { Instance, unprotect } from"mobx-state-tree";
    import Models from"../models";
    exportclassDataContext{
      static create() {
        const models = Models.create();
        unprotect(models);
        return models;
      }
    }
    export interface DataContext extends Instance<typeof Models> {}

    services / AuthService.ts
    import { observable } from"mobx";
    import { User } from"../models";
    exportclassAuthService{
      @observable currentUser: User;
    }

    services / PostService.ts
    import { inject } from"react-ioc";
    import { action } from"mobx";
    import { Post } from"../models";
    exportclassPostService{
      @inject dataContext: DataContext;
      @inject authService: AuthService;
      async publishPost(postInfo: Partial<Post>) {
        const response = await fetch("/posts", {
          method: "POST",
          body: JSON.stringify(postInfo)
        });
        const { id } = await response.json();
        this.savePost(id, postInfo);
      }
      @action
      savePost(id: string, postInfo: Partial<Post>) {
        const post = Post.create({
          id,
          rating: 0,
          date: newDate(),
          author: this.authService.currentUser.id,
          comments: [],
          ...postInfo
        });
        this.dataContext.posts.put(post);
      }
    }

    The main feature of the MobX State Tree is effective work with data snapshots. At any time, we can get the serialized state of any entity, collection, or even the entire state of the application using the function getSnapshot(). And in the same way we can apply snapshot to any part of the model using applySnapshot(). This allows us to initialize the state from a server in a few lines of code, load it from LocalStorage or even interact with it via Redux DevTools.


    Since we use the normalized relational model, we need the normalizr library to load the data . It allows you to translate tree JSON into flat tables of objects grouped by idaccording to the data scheme. Just in that format that MobX State Tree is necessary in quality snapshot.


    To do this, we define the schema of objects downloaded from the server


    import { schema } from"normalizr";
    const UserSchema = new schema.Entity("users");
    const CommentSchema = new schema.Entity("comments", {
      author: UserSchema
    });
    const PostSchema = new schema.Entity("posts", {
      // определяем только поля-связи// примитивные поля копируются без изменений
      author: UserSchema,
      comments: [CommentSchema]
    });
    export { UserSchema, PostSchema, CommentSchema };

    And load the data into the repository:


    import { inject } from"react-ioc";
    import { normalize } from"normalizr";
    import { applySnapshot } from"mobx-state-tree";
    exportclassPostService{
      @inject dataContext: DataContext;
      // ...async  loadPosts() {
        const response = await fetch("/posts.json");
        const posts = await response.json();
        const { entities } = normalize(posts, [PostSchema]);
        applySnapshot(this.dataContext, entities);
      }
      // ...
    }

    posts.json
    [
      {
        "id": 123,
        "title": "Иерархическое внедрение зависимостей в React",
        "body": "Довелось мне как-то после нескольких проектов на React...",
        "date": "2018-12-10T18:18:58.512Z",
        "rating": 0,
        "author": { "id": 12, "name": "John Doe" },
        "comments": [{
          "id": 1234,
          "text": "Hmmm...",
          "date": "2018-12-10T18:18:58.512Z",
          "rating": 0,
          "author": { "id": 12, "name": "John Doe" }
        }]
      },
      {
        "id": 234,
        "title": "Lorem ipsum",
        "body": "Lorem ipsum dolor sit amet...",
        "date": "2018-12-10T18:18:58.512Z",
        "rating": 0,
        "author": { "id": 23, "name": "Marcus Tullius Cicero" },
        "comments": []
      }
    ]

    Finally, register the services in the respective components:


    import { observer } from"mobx-react";
    import { provider, inject } from"react-ioc";
    @provider(AuthService, PostService, [
      DataContext,
      toFactory(DataContext.create)
    ])
    classAppextendsReact.Component{
      @inject postService: PostService;
      componentDidMount() {
        this.postService.loadPosts();
      }
    }

    It turns out all the same three-layer architecture, but with the ability to save the state and run-time data type checking (in DEV-mode). The latter allows you to be sure that, if no exception occurred, the state of the data warehouse corresponds to the specification.







    For those who were interested, a link to github and demo .


    Also popular now: