Zaczynając projekt, zastanawialiśmy się, o jaką strukturę oprzeć pliki z naszym kodem źródłowym. React sam w sobie w żaden sposób nie narzuca struktury plików, wręcz sugeruje, żeby na początku nie poświęcać na to zbyt dużo czasu: „Jeśli dopiero rozpoczynasz projekt, nie poświęcaj więcej niż pięć minut na wybór sposobu struktury plików”.
Postanowiliśmy więc zacząć od grupowania opartego o typ pliku, tzn.:
api/
...
components/
atoms/
molecules/
...
infrastructure
...
redux/
shared/
Po kilku miesiącach i wielu nowych feature’ach zaczęliśmy odczuwać, że to chyba jednak nie był najlepszy pomysł. Rosnąca złożoność kodu zaczęła być trudnością, prowadząc do problemów takich jak:
- Znalezienie plików powiązanych z daną funkcjonalnością wymagało skakania pomiędzy różnymi, czasem bardzo odległymi miejscami w bazie kodu (komponenty -> api -> redux -> …).
- Używanie fragmentów kodu, zaprojektowanych do użycia w konkretnym miejscu, w innych, tylko pozornie podobnych miejscach.
- Częstego rozproszenia uwagi w trakcie pracy nad feature’ami (lub review) ze względu na problem z określeniem czy dany fragment pliku lub komponent jest związany z danym feature’em.
W którymś momencie uznaliśmy, że jesteśmy w stanie zredukować tę złożoność za pomocą przeorganizowania struktury plików. Chcieliśmy odpowiedzieć na wskazane problemy jednocześnie ułatwiając pracę nad nowymi feature’ami.
Założenia wobec nowej struktury to:
- Grupowanie po feature’ach zamiast typie plików oraz pójście w stronę screaming architecture.
- Podwójna hierarchia kodu: moduły (mini-aplikacje) oraz feature’y.
- Ograniczenie kontekstu kodu, który trzeba brać pod uwagę przy pracy nad konkretnymi feature’ami.
Na początku podzieliliśmy aplikację na większe moduły domenowe, które grupują w sobie powiązane ze sobą feature’y. Następnie każdy moduł domenowy jest podzielony na funkcje.
portfolio/
project/
users/
features/
auth/
…
index.tsx
user-profile/
…
index.tsx
Wprowadziliśmy też jasne rozdzielenie pomiędzy elementami wewnętrznymi poszczególnych feature’ów a interfejsem udostępnianym na zewnątrz. Każdy feature określa co powinno być widoczne w pliku index.tsx. Pozostałe feature mogą importować tylko te zadeklarowane elementy. Dzięki temu pracując nad danym feature’em mamy jasność, które fragmenty kodu są bezpośrednio wykorzystywane na zewnątrz.
Dodatkowo istnieją dwa moduły specjalne:
- core - gromadzący w sobie wszystkie niedomenowe elementy, takie jak podstawowe komponenty, elementy związane z użyciem różnych zewnętrznych bibliotek, jak axios czy OData.
- container - łączący w sobie elementy udostępniane przez wszystkie moduły domenowe.
Efekty wynikające z takiego podejścia to:
- Chcąc znaleźć interesujący nas kod, przechodzimy przez przejrzystą, dwustopniową strukturę opartą o właściwości domenowe.
- Pracując nad danym feature’em możemy skupić się na konkretnym folderze (i podfolderach) w bazie kodu. Ogranicza to kontekst, o którym musimy myśleć w trakcie pracy.
- Konieczność umieszczania eksportowanych elementów w index.tsx naturalnie zwiększa ważność takiej decyzji i ułatwia zwrócenie na to uwagi w trakcie pracy oraz review. W ten sposób zyskujemy większą kontrolę nad tym, które elementy danego feature’a są używane na zewnątrz. W efekcie chcemy doprowadzić lepszej enkapsulacji kodu.
Co z kolei ma nas prowadzić do przyspieszenia i ułatwienia pracy, pozwalając efektywnie skupić się na aktualnym problemie do rozwiązania, bez dystrakcji wynikających z nieefektywnej struktury kodu.