DEV Community

Isaac Batista
Isaac Batista

Posted on • Edited on

Domain Layer and React? Decorators to the Rescue!

Why?

Yeah, why would I try to implement a domain layer within a frontend react application?

*To be able to change and test business rules in isolation from the rest of the system. *

All by myself

Business rules doesn't live in backend? Mostly, yes. But there are some rules that are attached to the view, they are about your form behavior, they are about how user may or not interact with your UI without making a request for every single movement.

Some rules are quite simple, like that isOpen for a modal. Others can become that big ugly component if your not paying enough attention. We're talking about these last guys.

There's nothing better than being able to test a complex logic in isolation. There's no UI, there's no API. Just the two of us.

We can make it if we try

Of course, you could isolate the logic inside a hook, and there's an awesome way of testing it with renderHook. But yet, you're trapped into react features, if they change their API, you will have to put your hands in your working logic and adapt some stuffs to keep up to date.

And that's ok if there's no complex domain logic or high life expectancy for the software.

So, repeating the why:

*To be able to change and test business rules in isolation from the rest of the system. *

How?

I have found this answer suggesting to wrap the model instance with 2 layers:

  • A ref layer keeping the mutable instance alive.
  • A projection layer keeping the instance data as a state and extending writting behaviors with setState calls.

For example, assume we have this fancy car model:

class Car {
  private speed = 0

  speedUp() {
    this.speed++
  }

  speedDown() {
    if(this.speed > 0) {
      this.speed--
    }
  }

  getSpeed() {
    return this.speed
  }
}
Enter fullscreen mode Exit fullscreen mode

We should keep the mutable instance with useRef:

const useCarRef = () => {
  const carRef = useRef<Car>()

  if(!carRef.current) {
    carRef.current = new Car();
  }

  return carRef.current
}
Enter fullscreen mode Exit fullscreen mode

We should also make a projection function that gets a DTO from instance data:

const projectCar = (car: Car) => {
  return { speed: car.getSpeed() };
}
Enter fullscreen mode Exit fullscreen mode

Last, but not least: we'll create the projection by wrapping write methods and saving the projection as state:

const useCarProjection = (car: Car) => {
  const [projection, setProjection] = useState(projectCar(car));
  return {
      ...projection,
      speedUp: () => {
        car.speedUp();
        setProjection(projectCar(car));
      },
      speedDown: () => {
        car.speedDown()
        setProjection(projectCar(car));
      }
  };
}
Enter fullscreen mode Exit fullscreen mode

Finally we could mix it all and render a component:

const CarView: React.FC = () => {
  const carRef = useCarRef();
  const car = useCarProjection(carRef);

  return <div>
    <button onClick={car.speedDown}>-</button>
    <span>Speed: {car.speed}</span>
    <button onClick={car.speedUp}>+</button>
  </div>
}
Enter fullscreen mode Exit fullscreen mode

Nice!

That's really nice! But I was studying some design patterns when I read this solution and I could not help noticing that the decorator pattern pretends exactly to wrap some original class adding some behavior.

Quick steps:

  • Create an interface with the class behaviors
  • Create a decorator base class that just wraps an instance and it's methods.
  • Create the specialized decorator that executes base behavior and adds some new.

So, would it fit this application? YES!

First, we should incorporate the projectCar function to Car. I'll also rename it to getDTO.

This is our car now:

class Car {
  private speed = 0

  speedUp() {
    this.speed++
  }

  speedDown() {
    if(this.speed > 0) {
      this.speed--
    }
  }

  getDTO() {
    return {
      speed: this.speed
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Neat.

Now, we have to create an interface for our Car. That's the contract that will be decorated.

interface CarDTO {
  speed: number
}

interface CarInterface {
  speedUp(): void;
  speedDown(): void;
  getSpeed(): number
  getDTO(): CarDTO
}

class Car implements CarInterface {
  private speed = 0

  speedUp() {
    this.speed++
  }

  speedDown() {
    if(this.speed > 0) {
      this.speed--
    }
  }

  getSpeed() {
    return this.speed;
  }

  getDTO() {
    return {
      speed: this.speed
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

With the contract, we can build the base of our decorator. He's really just a wrapper for the car instance:

class CarDecorator implements CarInterface {
  constructor(protected wrapped: CarInterface){}
  speedUp(): void {
    this.wrapped.speedUp()
  }
  speedDown(): void {
    this.wrapped.speedDown()    
  }
  getSpeed(): number {
    return this.wrapped.getSpeed()
  }
  getDTO(): CarDTO {
    return this.wrapped.getDTO()
  }
}
Enter fullscreen mode Exit fullscreen mode

NOW we can add the true decoration (additional behavior), extending from this base, overriding the write methods (exactly as we did before at useCarProjection hook). But first, as the behavior involves triggering another function, we need to receive it and keep it.

class CarProjectionDecorator extends CarDecorator {
  constructor(
    wrapped: CarInterface,
    private setProjection: (projection: CarDTO) => void 
  ){
    super(wrapped)
  }

  speedUp(): void {
    super.speedUp()
    this.setProjection(this.getDTO())
  }
  speedDown(): void {
    super.speedDown() 
    this.setProjection(this.getDTO())
  }
}
Enter fullscreen mode Exit fullscreen mode

The projection decorator only responsability is calling original behavior and adding something more, in this case: state update trigger. Note that decoration could be added to getters as well, but their original behavior already fits our case.

We can still use the hook just to launch the state and deliver the data to the requesting component.

const useCarProjection = (car: Car) => {
  const [_projection, setProjection] = useState(car.getDTO());
  return new CarProjectionDecorator(car, setProjection)
}
Enter fullscreen mode Exit fullscreen mode

The _projection is not really needed because all the data is available through CarProjectionDecorator getters.

Usage doesn't change much:

const CarView: React.FC = () => {
  const carRef = useCarRef();
  const car = useCarProjection(carRef);

  return <div>
    <button onClick={car.speedDown}>-</button>
    <span>Speed: {car.getSpeed()}</span>
    <button onClick={car.speedUp}>+</button>
  </div>
}
Enter fullscreen mode Exit fullscreen mode

The only difference is that we are now using the getters instead of accessing directly the DTO properties.

Conclusion

Our car is now isolated and the behavior addition is being executed in a class with defined responsability with a well known design pattern with the cons of increasing the complexity by adding more abstraction layers and more files.

Extra - real application

As a reward for reading this all, here's an example of a real application. It's from a RPG character creator in which user will buy his character attribute points. At github.

Domain class:

export class AttributesLauncherPerPurchase implements AttributesLauncherPerPurchaseInterface {
  static price: Record<number, number> = {
    [-1]: -1,
    [0]: 0,
    [1]: 1,
    [2]: 2,
    [3]: 4,
    [4]: 7
  }

  static defaultAttributes = {strength: 0 , dexterity: 0, constitution: 0,  intelligence: 0 , wisdom: 0, charisma: 0  }

  private points = 10;

  constructor(private attributes: Attributes = AttributesLauncherPerPurchase.defaultAttributes){}

  confirm(): void {
    if(this.points > 0) {
      throw new Error('POINTS_LEFT')
    }
  }

  increment(attribute: Attribute): void {    
    const currentAttribute = this.attributes[attribute]
    if(this.points === 0) {
      this.attributes[attribute] = currentAttribute;
      return
    }
    if(currentAttribute >= 4) {
      this.attributes[attribute] = 4;
      return 
    }
    const attributeResult = currentAttribute + 1;
    const totalResult = this.points - Math.abs(AttributesLauncherPerPurchase.price[currentAttribute] - AttributesLauncherPerPurchase.price[attributeResult])

    if(totalResult < 0) {
      this.attributes[attribute] = currentAttribute
      return
    }
    this.points = totalResult
    this.attributes[attribute] = attributeResult;
  }

  decrement(attribute: Attribute): void {
    const currentAttribute = this.attributes[attribute]
    if(currentAttribute <= -1) {
      this.attributes[attribute] = -1
      return
    };

    const result = currentAttribute - 1;
    this.points += Math.abs(AttributesLauncherPerPurchase.price[currentAttribute] - AttributesLauncherPerPurchase.price[result])
    this.attributes[attribute] = result;
  }


  getAttributes(): Attributes {
    return this.attributes
  }

  getPoints() {
    return this.points
  }

  getDTO(): AttributesLauncherPerPurchaseDTO {
    return {
      attributes: this.getAttributes(),
      points: this.getPoints()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Base decorator:

// AttributesLauncherPerPurchaseDecorator.ts
export class AttributesLauncherPerPurchaseDecorator implements AttributesLauncherPerPurchaseInterface {
  constructor(protected attributesLauncherPerPurchase: AttributesLauncherPerPurchaseInterface){}

  confirm(): void {
    this.attributesLauncherPerPurchase.confirm()
  }

  getDTO(): AttributesLauncherPerPurchaseDTO {
    return this.attributesLauncherPerPurchase.getDTO()
  }

  getAttributes(): Attributes {
    return this.attributesLauncherPerPurchase.getAttributes()
  }

  getPoints() {
    return this.attributesLauncherPerPurchase.getPoints()
  }

  increment(attribute: Attribute): void {    
    this.attributesLauncherPerPurchase.increment(attribute)
  }

  decrement(attribute: Attribute): void {
    this.attributesLauncherPerPurchase.decrement(attribute)
  }
}
Enter fullscreen mode Exit fullscreen mode

Projection decorator:

// AttributesLauncherPerPurchaseProjectionDecorator.ts

export class AttributesLauncherPerPurchaseProjectionDecorator extends AttributesLauncherPerPurchaseDecorator {
  constructor(
    attributesLauncherPerPurchase: AttributesLauncherPerPurchaseInterface,
    private setProjection: (projection: AttributesLauncherPerPurchaseDTO) => void
  ){
    super(attributesLauncherPerPurchase)
  }

  confirm(): void {
    super.confirm();
    this.setProjection(this.getDTO())
  }

  increment(attribute: Attribute): void {    
    super.increment(attribute)
    this.setProjection(this.getDTO())
  }

  decrement(attribute: Attribute): void {
    super.decrement(attribute)
    this.setProjection(this.getDTO())
  }
}
Enter fullscreen mode Exit fullscreen mode

View

const AttributesLauncherPerPurchaseView: React.FC = () => {
  const {sheetBuilderForm} = useSheetBuilderFormContext()
  const attributesLauncher = sheetBuilderForm.getAttributesLauncher()
  const attributes = attributesLauncher.getAttributes();
  return (
    <div>
      <h3 className='mb-3'>Compra de pontos</h3>
      <div>Restante: {attributesLauncher.getPoints()}</div>
      <div className="flex justify-evenly mb-3">
        {Object.entries(attributes).map(([key, value]) => {
          const attribute = key as Attribute;
          return (
            <AttributeInput 
              key={attribute} 
              attribute={attribute} 
              value={value} 
              decrement={() => attributesLauncher.decrement(attribute)} 
              increment={() => attributesLauncher.increment(attribute)} 
            />
          )
        })}
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

That's it, til next app.

Top comments (0)