Simple Domain Architecture for React

"React.js - A JavaScript library for building user interfaces". Out of the title of react on its homepage, we can clearly understand: react is not having any given architecture, since its not a framework, just a thin library.

Before I start discussing the problems with the missing architecture in react, I want to refer to the idea of a "domain driven design".

Here is how Martin Fowler is describing it:

"Domain-Driven Design is an approach to software development that centers the development on programming a domain model that has a rich understanding of the processes and rules of a domain. The name comes from a 2003 book by Eric Evans that describes the approach through a catalog of patterns. Since then a community of practitioners have further developed the ideas, spawning various other books and training courses. The approach is particularly suited to complex domains, where a lot of often-messy logic needs to be organized."

In my work practice I usually refer to a domain if I talk about the area of expertise of a piece of code.

It can be the business domain, like the banking domain in a fin tech application. It can also be the technical domain, like HTTP logic handling, the error handling and configuration for REST communication.

This abstraction makes it possible to stay away from all kind of architectural patterns known in DDD and just to use the idea of the code responsibility to a certain type of logic (=domain).

With that in place, we can start a discussion about the react.js architecture.

So what might be the signs of a missing react architecture?

  • there is no separation between pure UI and domain specific UI logic, you will not be able to extract your pure layout components so other projects inside your company can reuse it
  • following the smart and dumb components philosophy, the pure UI components are so dumb, that you have a hard time using them, countless functions are passed into the component without any default values in place, so you feel you need to know everything about them, just to be able to use them
  • a new developer is supposed to reuse all the logic in the system, but he does not know where to find it, the whole code base consist of single exported methods with abstract names and can only be found by reading through a major chunk of code base, auto completion is not possible without knowing the single method names
  • architectural layers are all mixed up, the highest view components (like smart components) are directly handling http response codes, e.g. there is no central place to execute error handling in one way for all error cases
  • the concept of domain specific logic does not exist, each feature is just randomly building up its calculation for something like a banking transfer, this is happening directly in the "smart component" mixed up with its own UI logic

You can describe it as the result of the given architectural freedom. We had similar situations before the opinionated frameworks came into play.
I remember very well the time as RubyOnRails came around 2006. As a main stream framework it had an architecture build in. All major startup products were written in RubyOnRails for almost a decade with a lot of success. RoR had this tremendous success because it gave direction and a complete, out of the box architecture and a fully configured technical stack.

So why are we abolishing those lessons learned?

But solutions are on the way, it just takes longer then it should be...

Angular for example, does NOT have this issue, its having a build in architecture, but this is just a site note.

Also, we have frameworks arriving into front end space (e.g. Next.js), that understood this issue and are building more and more opinions into their architecture. Next.js is still missing a domain oriented approach though. To make it short: its missing a separation into domain and/or technical services as part of the framework.

Since no major domain orientation is there, you are better of if you address this topic and define an architecture within your team, that fits for everyone.

One architectural style that greatly worked for me in many projects is what I want to present in this post.

I call it the "simplified domain architecture".

Here is how it looks like...
UI Plain Domain

holds only ui components, that are not bound to the business, so imagine that you would write some other application and can just reuse your UI components, like text field, button, form and panel. UI plain components should be targeted as a sharable library for you whole corporation.

A very elegant approach is provided by the nx.dev monorepo framework, where you can easily define lib's you want to share. Important is to mention that, the plain ui domain should never reference any context api or domain services, to be fully sharable.You see just incoming dependencies to it, in the diagram.

UI Business Domain

Next is the business UI domain, here you have the business oriented features. With the arrows in the diagram you see who is referencing what. The ui business domain is referencing mainly the plain ui and the business service domain, to implement the business feature. Important: it can also reference some tech service and this is fine. Best sample would be a routing service, it is the UI domain but it can be considered a tech service, since its not a domain specific service.
Service Business Domain

Those are your business specific services, for your banking application you would coordinate business flows and business logic here. The business domain is using the tech services to execute its tasks. It creates also an abstraction over pure tech. That way a business service can decide in an error case about right error handling before passing it to the UI. The UI is having an easier life plus you never repeat yourself since you consolidate logic for one business case in your business service. The UI is only concerned with pure UI tasks the rest is propagated to the business service.

Here is a real life example of a service like that...
import parsePhoneNumber from "libphonenumber-js/max";

const DEFAULT_COUNTRY = "DE";

const INVALID_NUMBER_MSG = "Your phone number is not valid.";

const readPhoneNumber = (value: string) => {
  return parsePhoneNumber(value, {
    defaultCountry: DEFAULT_COUNTRY,
    extract: false,
  });
};

const isValidNumber = (value: string, allowEmpty = false): boolean => {
  if (allowEmpty && !value) {
    return true;
  }
  const phoneNumber = readPhoneNumber(value);
  return phoneNumber ? phoneNumber.isValid() : false;
};

const formatNumberInternationally = (value: string): string => {
  const phoneNumber = readPhoneNumber(value);
  return phoneNumber ? phoneNumber.formatInternational() : value;
};

const PhoneNumberService = {
  INVALID_NUMBER_MSG,
  isValidNumber,
  formatNumberInternationally,
};

export default PhoneNumberService;
So what is the domain of this PhoneNumberService? Its the specifics of phone number validation and formatting. Is this a business domain? Yes, we deal with some info from the real world, here the phone numbers.

Some other things to point out

  • clear encapsulation of private methods
  • a clear api to the outside
  • encapsulation of an external library (import libphonenumber)
  • reuse of an internal private method
  • some validation message that belongs to this domain
  • any UI component using it, will get logic from a single place

With that in place, see how easy it becomes to use the service. The service name is giving the method call a clarifying context, so the methods can be much shorter, its very clear what is being done just by reading it...
Service Tech Domain

Everything that is not business, can be seen as your tech service, it is also app infrastructure. Typical samples are

  • HTTP, you wrap your HTTP client library here
  • Logging, setting up log levels
  • Environments, wrap your ENVs, calculate URLs for back end
  • I18N, translation logic
  • DateTime, wrap external lib's like moment.js
  • Routing of the application, wrap standard routing into methods

I consciously don't want to start a puristic discussion here, to let the architecture and the rules be simple. Let it be just business domain and tech domain.

Global State Management

If you have a mid size react app with nothing special you should be fine using just the context api, that comes out of the box with react. You can see a good intro here on how to do it.

Important is to understand that

  • you can use the context from everywhere as long as you structure your service methods as hooks
  • if you don't need a service method to be a hook, than make it a simple function, this makes the usage of it more flexible and simpler
  • ideally you have your context only in the business ui domain and your business domain, but this is not always possible
  • I wrote complete projects with this architecture completely avoiding a global state in the system, try to delay the introduction of global state to the latest, but if you know you need it, clearly define where it will be used

Directed dependency flow

You see the dependencies direction in the diagram. Its mostly unidirectional top down, same es it would be in the back end as well. Obviously tech services don't know about the ui and business domain. The business domain picks things out of the system that it needs, so it is referencing the tech services from all levels always in one direction. Of course can a tech service also reference another service, like HTTP service can reference the EnvService to figure out the URL it needs to use. Tech service though can never reference higher layers, like the business or the UI. Specifically its forbidden to mix up objects from different layers in the signature of the methods. Like passing around HTTP domain specific object types to other domains.

Service Objects

The first thing that surprised me a lot in the JavaScript projects, is the sloppy way of handling the visibility of methods, just everything is exported any variable and any method. Additionally this exports are mostly placed in single files! Not only you pollute the code name space you also have thousands of single files. I understand the original motivation behind it, its tree shaking. This is all fine if you import lodash and just want to use a single method from it, but why would you be willing to tree shake your own methods, if your service is just having 5 of them, which is a tiny size and you have all of them used in your application? Will the little tree shaking of own methods decrease your bundle size so much that you need to build your whole repo that way? I really don't think so, this is not where you should be optimizing your bundles.

To fix that I came up with the object service pattern, so if I have some domain logic, instead of a single exported method you create a service that will potentially hold all logic of this type.
export interface MyDomainType {
  someAttrib: string;
}

const myPrivateMethod = () =>{
  console.log('Do something...')
}

const myMethod = (param?: MyDomainType): MyDomainType => {
  myPrivateMethod()
  console.log('called with', param);
  return { ...param };
};

export const MyDomainService = {
  myMethod,
};

export default MyDomainService;
Here are the advantages of the service object pattern

  • create clear private and public visibility with encapsulation
  • give name space and readability if you call your methods
  • easy auto complete by writing out the service name
  • easier logic reuse and recognition of repetition
  • clear place for other team members to look for written logic
Advantages of this architecture

  • pragmatic: out of my experience (its 20 years) this is a perfect balance between a puristic architecture and no architecture at all, you can have the most clean architecture, but will you be able to hold it in a realistic project? Specifically, will your junior team members clearly understand it? We need generally a balance of theory and practical application...
  • easy to introduce: show the theory to a team and apply if everyone agrees, no need to restructure the whole project, it can happen incrementally and only for new features, because it does not have a bunch of conventions or a fancy folder structure
  • back end best practices: it is very similar to a spring back end architecture, many back end dev's will feel comfortable with that approach, since this is what they mostly do
  • logic in a single place: did you ever have this feeling of not centralized logic, a feel that no central place clearly exists for some type of logic? the domain approach forces developers to clearly push logic types to the right place
  • domain name spaces: the JS eco space is known for exporting many single functions just to the global name space, in the given architecture you can easily write out a service name like "UserService." and just use the auto completion of your IDE, this has tremendous value for your code reuse, since present code can be found easily, the rule is "don't read, just auto complete"
  • no other patterns: forget any discussion about helpers (because it manipulates strings), utils (because it has no object focus), apis (because it goes to some external point) or any other new kind of pattern, its just "YourSomethingService", it gives your logic its area of task and it can be found easily by others, the team basically agrees on one main pattern that performs for the most situations well
  • react friendly: with the hooks down to the tech services you always can use the global context api to consume or write to state if this is needed in your architecture
  • encapsulation of external libs: any external library is placed into a corresponding service, even the global browser objects like localStorage, window, events are placed into a service and are abstracted away, this again shows you are better of by NOT introducing different kind of patterns, you just separate domain logic groups
  • test friendly: by separating your app into layers and services it becomes very test friendly, this is a known architectural result, in my tests I call the same infrastructure services like EnvService or InitializationService that are being used in the app
Final Thoughts

This architecture is not puristic, there are more puristic approaches, but each architecture brings its costs with it. With the given architecture I tried to create a perfect balance between simplicity, domain driven design ideas and a more puristic approach like а CLEAN architecture.

I will discuss an architecture (CLEAN) that is more puristic
in my next blog post...
Made on
Tilda