Coder Social home page Coder Social logo

Scaling this to the practical level - replacing store with big external state management library. about frontend-clean-architecture HOT 3 CLOSED

bespoyasov avatar bespoyasov commented on May 23, 2024
Scaling this to the practical level - replacing store with big external state management library.

from frontend-clean-architecture.

Comments (3)

WingsDevelopment avatar WingsDevelopment commented on May 23, 2024 2

Thank you so much for the answer!

Everything looks great.
I got confused because the line const storageService = useCounterStorage(); in your usecase, forces you to turn everything into hooks because of the hook rules... Which is changing your implementation of the domain. However I missed the part where you are talking about dependency injection, in order to truly decouple this things. So my plan now is to write my counter storage like this and inject it in my application layer..
@Injectable()
export class CounterStorage implements ICounterStorage {
getValue = () => store.getState().counterState;
increment = () => store.dispatch(increment());
decrement = () => store.dispatch(decrement());
incrementByAmount = (amount: number) => store.dispatch(incrementByAmount(amount));
}

Your clean architecture blog is the best blog and the reason why I am starting on front-end :D Keep up the good work !

from frontend-clean-architecture.

bespoyasov avatar bespoyasov commented on May 23, 2024 1

Hey!

First of all, any architecture is always a tradeoff. There can't be a ”silver bullet solution“ for every possible problem in the development.

Secondly, I'm not sure I can consult about the architecture for a particular case without digging into the details of the project and researching its constraints and goals. So I assume this answer is not a manual but rather a ”first look at a project“. It shouldn't be treated as “the one and only way how to design the project“ but rather as one of many things that “may be taken into consideration“.

redux-toolkit would actually change architecture of your code a little bit, by changing your domain interfaces for storage.

Let's start with the domain. Redux-toolkit can't change the domain because there's nothing related to redux in the domain. The domain layer contains only the core logic of the app.

Domain

It doesn't contain any interfaces related to the store. In my opinion (which can be disagreed with), the domain functions and entities must not know anything about the store. For instance, in the example on Stackoverflow the domain function would be just the increment:

type CounterValue = number;
type CounterDiff = number;

type Counter = {
  value: CounterValue;
}

function incrementBy(current: Counter, difference: CounterDiff): Counter {
  const value = current.value + difference;
  return { ...current, value }
}

// The example might look exaggerated, 
// it's just used to illustrate the principle.

Application

So the domain layer is ”clean“ and doesn't depend on Redux-toolkit. The application layer, however, does contain interfaces related to the store (ports), and Redux-toolkit can affect those.

The main goal for me, when I connect external libraries to the project, is to avoid “fine-tunning” my code to make it possible to use with the external lib. Instead, I use entities that help to ”adapt“ the external lib to my code.

In the case of Redux-toolkit, the process might be split into multiple steps.

Slice Entities

We can use slices in different ways:

  • slice per domain entity;
  • slice per use case;
  • slice per feature / package / app slice etc.

I'll go with the “slice per domain entity” approach because it's just an example and is easier t understand. In your project, you can use any of the options above or think of any else.

Starting with createSlice. It consumes an entity as an initialState. In many cases, the entity can be a domain entity, and in most cases, it is enough:

const initialState: Counter = { value: 0 };

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    // ...TODO.
  },
})

Sometimes we might need to use a DTO here, and, in fact, using a DTO is ”cleaner“. But again, this is just an example. Whether to use a DTO depends on many factors and we won't go there now.

So far, Redux-toolkit hasn't affected the design.

Synchronous Updates and Adapter

The next step is to add reducers and handle state updates. In the Redux-toolkit docs, there's an example of adding reducer functions in the slice:

export const counterSlice = createSlice({
  name: 'counter',
  initialState,

  reducers: {
    increment: (state) => {
      state.value += 1
    },
    decrement: (state) => {
      state.value -= 1
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload
    },
  },
})

Here, in these reducers, I can see how it's possible to use the domain functionality:

const increment = state => incrementBy(state, 1)
const decrement = state => incrementBy(state, -1)
const incrementByAmount = (state, action) => incrementBy(state, acion.payload)

Basically, we can use the domain function inside the reducers, so the reducer functions would become adapters to the lib. It isn't always required. Sometimes you can just use the domain function as it is if the signature allows it.

So, then the reducers would become:

export const counterSlice = createSlice({
  name: 'counter',
  initialState,

  // As far as I know, RTK allows to return the updated state as well as to mutate it.
  reducers: {
    increment,
    decrement,
    incrementByAmount,
  },
})

Again, no changes in the domain layer so far.

and you will end up with single reducer that sets whole slice

You can use 1 single reducer or 3 different ones here, no problem with that. The main goal was to distinguish the “service” functionality (RTK, slices, and stuff) and the ”core“ logic (the real math of incrementing and decrementing).

The TRK stuff is surrounding the main logic, it doesn't dictate how to write the increment function. Instead, it wraps this logic and provides a way to communicate this logic with the components.

Application Layer

So far, we haven't touched the user interface and application layer. For that, I'm going to use an asynchronous update as an example.

Let's say we have a use case when the user needs to make an async call and then call the update. Imagine, the user needs to read the value from the buffer and use it as the difference to the increment:

interface ClipboardService {
  readFrom(): Promise<number>
}

const browserClipboard: ClipboardService = {
  readFrom: async () => {
    const value = await navigator.clipboard.readText();
    return Number(value);
  }
}

// I skip the error handling, that's a topic on its own.

...And we have a use case:

async function incrementByClipdboardValue() {
  // Take the value from the clipboard;
  // Call the increment action.
}

In the example I used in the post the use case would look like this:

async function incrementByClipdboardValue() {
  const clipboardService = browserClipboard;
  const storageService = useCounterStorage();

  const difference = await clipboardService.readFrom();
  const currentValue = storageService.value;

  const updated = incrementBy(currentValue, difference)
  storageService.update(updated)
}

In the case of RTK, we can either build the same structure or use their reducers field to decrease the amount of work we need to do.

(If we had just a single reducer, we could keep the use case structure the same. If we have 3 different reducers the internal part of the use case will change. However, we still can keep the interfaces almost the same.)

Storage Service

RTK offers the actions and selectors to call the updates and read values from the store. We can either use them in the use case or create an adapter for them.

I wouldn't use the actions and selectors шт еру сщьзщтутеы right away because it exposes the whole codebase to the ”library way“ of writing the code. (I would need to use dispatch everywhere I needed to call an action, this is high coupling.)

For the storage service, we could come up with something like that:

interface CounterStorage {
  getValue(): Counter; // Can also be `CounterValue`, depends on your preferences.
  
  increment(): void;
  decrement(): void;
  incremenyBy(difference: CounterDiff): void;
}

The implementation of this service would be an adapter:

const { increment, decrement, incrementByAmount } = counterSlice.actions;
const selectCounter = (state) => state.counter;

export function useCounterStorage(): CounterStorage {
  const dispatch = useDispatch()

  return {
    getValue: () => useSelector(selectCounter),

    increment: () => dispatch(increment),
    decrement: () => dispatch(decrement),
    incrementBy: (amount) => dispatch(incrementByAmount, amount),
  }
}

In the use case, it would be used like:

async function incrementByClipdboardValue() {
  const clipboardService = browserClipboard;
  const storageService = useCounterStorage();

  const difference = await clipboardService.readFrom();
  
  // The domain logic is hidden inside the `incrementBy`
  // but it is still between the “Input Ports” and “Output Ports” functionality.
  // The Impureim sandwich principle doesn't get violated.

  storageService.incrementBy(difference);
}

Performance and Reader / Writer

In some cases, you wouldn't want to mix the reading from the store and writing to it. Because of performance issues or because of the desired “purity” for the code. Then, you can split the interface (apply ISP):

interface CounterStorageReader {
  getValue(): Counter;
}

interface CouterStorageWriter {
  increment(): void;
  decrement(): void;
  incremenyBy(difference: CounterDiff): void;
}

In some cases, you would want to split the interface even more. (Because of performance concerns or because of functionality atomicity.)

Use Case and Thunks

So far, it was “kinda problematic“ to use the async use case with RTK because RTK, by default, implies using thunks. But we actually can use the use case function in a thunk. The thunk function would become an adapter to RTK:

const incrementByClipboardValue = createAsyncThunk(
  'counter/incremenyByClipboard',
  async (thunkApi) => {
    incrementByClipdboardValue()
  }
)

Moreover, if we need we can use this thinkApi argument as a “Dependency Injector”. Yeah, it isn't so ”clean“ and “pure” but it still is automatic and provides us with all the required services for the store.

type Storage = ThunkAPI
type UseCaseDependencies = {
  storage: Storage,
  clipboard: ClipboardService
}

async function incrementByClipdboardValue(UseCaseDependencies)

// ...

const incrementByClipboardValue = createAsyncThunk(
  'counter/incremenyByClipboard',
  async (thunkApi) => {
    incrementByClipdboardValue({storage: thunkApi, clipboard: clipboardService})
  }
)

Again, the main logic is decoupled from the library. The domain is kept in the domain layer, the use case is kept in the application layer.

The main goal here is not to ”mix unmixable“ and try to fine-tune the code so that it fits the RTK requirements. Instead, the goal is to design the ”core“ application logic first, then create and design the use cases we're going to need in the app. And only after that look at the library constraints and figure out a way to fit this lib to our app constraints, not otherwise.

There are, of course, many other ways of using thunks together with the use case functions. Also, there are many other ways of designing the reducers field in the slice as well as using those with the domain. So I can't simply tell “how to do” and “how not to do” because “it always depends“ :–)

In Total

In projects, I tend to see architecture as a tool and not as a goal. The main goal is to create an app that is maintainable, extensible, and readable code.

When, writing code, I feel like ”something is off“ I start to question myself if I really should listen to some random folk on the Internet and try to fit their way of describing things. (Yup, my way of writing the code can be wrong, it's just something I wanted to share because I used that a lot and it helped me but it might not be representative.)

It's okay to not agree with this style and just use the examples RTK provides. Especially, if the project isn't going to be re-written or extended a lot.

It's also okay to use just a part of it. For example, one can just extract the core logic into the domain functions for easier testing and be happy with that. All the other code they can write as they are used to or as it is accepted in their team.

It's also okay to use only the “type-interface” part of this and ignore implementation details and everything else. And it's definitely better to “just use the examples from the docs” if the result is going to be cleaner and more readable that way.

The good architecture is not the “clean”, “onion”, or “hexagonal”, the good architecture is the one that allows you to extend and maintain the app.

I hope my explanation made it a bit easier to understand how you can apply this stuff in your project without trying to fit every possible and impossible detail into the style of architecture from the post. And I hope I managed to explain how to select the good parts for your project.

Cheers!

from frontend-clean-architecture.

Related Issues (11)

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.