Closed WingsDevelopment closed 2 years ago
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.
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.
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.
We can use slices in different ways:
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.
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.
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.)
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);
}
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.)
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 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!
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 !
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 !
I write a implementation of incrementByClipdboardValue
when we use it in thunk
async function incrementByClipdboardValue({ storage, clipboard }) {
const difference = await clipboard.readFrom();
storage.incrementByAmount(difference);
}
Hello, I see that implementation of storage is realized in useContext. Which is fine, that is just an implementation and nothing more.
But I am concerned that bringing some big external library like redux-toolkit would actually change architecture of your code a little bit, by changing your domain interfaces for storage.
How ? Well redux brings actions, reducers, dispatching, and middlewares for async communication. And currently I can't see how can you completely separate all of this from your domain logic, and hide it behind your interfaces.
Also even if you manage to do it somehow ( like I did somehow.. somehow I mean it changed my domain interfaces but I managed to hide it again) it is still going to be against what is redux-toolkit paradigm, and you will end up with single reducer that sets whole slice.. I am not sure if that's okay?
Here is what I managed to do and all my concerns in one place : https://stackoverflow.com/questions/71649550/clean-architecture-react
I hope you will find the time to answer this. Thanks for your time.