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. *
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.
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 withsetState
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
}
}
We should keep the mutable instance with useRef:
const useCarRef = () => {
const carRef = useRef<Car>()
if(!carRef.current) {
carRef.current = new Car();
}
return carRef.current
}
We should also make a projection function that gets a DTO from instance data:
const projectCar = (car: Car) => {
return { speed: car.getSpeed() };
}
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));
}
};
}
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>
}
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
}
}
}
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
}
}
}
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()
}
}
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())
}
}
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)
}
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>
}
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()
}
}
}
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)
}
}
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())
}
}
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>
)
}
That's it, til next app.
Top comments (0)