1. Uczciwe uproszczenie nie łamie granic
Gdy mówi się „edukacyjne uproszczenie", często wyobraża się kod w stylu „no dobra, na razie niech jakoś będzie" 😅. Ale to zły typ uproszczenia. On naprawdę niszczy architekturę, rozmywa odpowiedzialność i potem przeszkadza w każdym wzroście. Potrzebujemy innego podejścia: upraszczać infrastrukturę, a nie samą logikę warstw.
Warto trzymać w głowie prostą tabelę:
| Co pozostaje na poziomie produkcyjnym | Co jest świadomie upraszczane |
|---|---|
| Kontrakt HTTP, statusy, forma JSON odpowiedzi | zamiast prawdziwej bazy danych — warstwa przechowywania in-memory |
| DTO i granica zewnętrznego modelu | przechowywanie plików pozostaje lokalne |
| walidacja i kontrakt błędów | brak auth, users, roles, tokens |
| struktura warstwy webowej i serwisowej | brak messagingu, cloud storage i distributed runtime |
To bardzo zdrowa kombinacja. Nie mówimy: „skoro nie ma bazy, możemy trzymać wszystko w kontrolerze". Nie mówimy: „skoro nie ma security, błędów nie trzeba projektować". Mówimy: „główna wartość edukacyjna to zewnętrzny kontrakt API; wszystko, co nie pomaga go wyraźniej zobaczyć, teraz lepiej uprościć".
Takie rozwiązanie jest szczególnie ważne dla kursów samodzielnej nauki. Gdy student przechodzi kurs sam, potrzebuje czystego sygnału. Jeśli w tym samym momencie próbuje rozgryźć i URI, i @ControllerAdvice, i lazy loading, i 401/403, i pliki infrastrukturalne — nauka zaczyna się rozpadać. Uczciwie ograniczony zakres działa tu nie jak ograniczenie, lecz jak wzmacniacz fokusu.
2. In-memory repository
Najczęstszy błąd przy rezygnacji z bazy danych brzmi tak: „No skoro bazy na razie nie ma, trzymajmy po prostu List<Task> w kontrolerze" 🙈. Wydaje się, że to prostsze. W rzeczywistości niszczy granice szybciej niż przynosi korzyści.
Nawet bez bazy danych aplikacja i tak potrzebuje osobnego punktu odpowiedzialności za przechowywanie danych. Dlatego w projekcie pozostaje repozytorium — jako warstwa i jako idea. Nie zmienia się sama granica przechowywania, tylko technologia pod nią.
Minimalny interfejs może wyglądać tak:
public interface TaskRepository {
// Pobranie wszystkich zadań z magazynu (szczegóły implementacji nas nie interesują)
List<Task> findAll();
// Znalezienie zadania po id; Optional podkreśla, że zadanie może nie istnieć
Optional<Task> findById(String id);
// Zapisanie zadania (w bazie byłby INSERT/UPDATE, tu — zapis w pamięci)
Task save(Task task);
}
A prosta implementacja in-memory może wyglądać tak:
public class InMemoryTaskRepository implements TaskRepository {
// Wewnętrzny magazyn: klucz — id zadania, wartość — samo zadanie
// Ważne: to szczegół implementacji; na zewnątrz nie powinien "wyciekać"
private final Map<String, Task> storage = new HashMap<>();
@Override
public List<Task> findAll() {
// Zwracamy kopię listy wartości, żeby nie oddawać na zewnątrz widoku kolekcji Map
return new ArrayList<>(storage.values());
}
@Override
public Optional<Task> findById(String id) {
// Pobieramy z Map po kluczu; jeśli nie ma — dostajemy null, który zawijamy w Optional
return Optional.ofNullable(storage.get(id));
}
@Override
public Task save(Task task) {
// Zapisujemy/nadpisujemy zadanie po jego id
storage.put(task.getId(), task);
// Zwracamy zapisane zadanie (często wygodne dla warstwy serwisowej)
return task;
}
}
Co tu ważne zobaczyć? Forma architektoniczna pozostaje dojrzała. Jest warstwa przechowywania. Jest interfejs. Jest implementacja. Warstwa serwisowa nie musi wiedzieć, że pod spodem jest HashMap, a nie PostgreSQL. Kontroler — tym bardziej.
To właśnie uczciwe uproszczenie. Nie ruszamy SQL ani ORM w tym kursie, ale też nie zamieniamy projektu w bałagan. Później implementację in-memory można zastąpić repozytorium JPA — to już będzie inny kurs i inny akcent. Ale warstwa web/API nie powinna przez to nagle być przepisywana od zera.
Warto tu osobno zauważyć jeszcze jedną rzecz. In-memory storage nie sprawia, że projekt jest automatycznie „zabawkowy". Tak, dane znikają po restarcie. Tak, nie ma transakcji. Tak, prawdziwego zachowania persistence tu nie modelujemy. Ale to uczciwie nazwane ograniczenia, a nie ukryty problem. Za to w zamian bardzo wyraźnie widać, gdzie przebiega granica między API, serwisem a przechowywaniem.
3. Dlaczego JPA na razie tylko odwraca uwagę
JPA to potężny i przydatny temat. Ale łatwo przejmuje całą rozmowę 🙂. Gdy tylko wprowadzisz go do projektu, razem z nim przychodzą @Entity, strategie fetch, lazy loading, generowanie ID, mappingi, granice transakcji, ewolucja schematu, migracje i mnóstwo niuansów, które właściwie zasługują na własny kurs.
Gdyby dodać JPA tu „na wszelki wypadek", nastąpi prosty przesun uwagi. Zamiast dyskutować o tym, jaki JSON oddawać klientowi i jak formować kontrakt błędów, zaczniesz omawiać, dlaczego coś się nie zapisało, jak mapować relacje albo dlaczego lista nagle robi dodatkowe zapytania SQL. To wszystko normalne pytania inżynierskie — po prostu nie na ten kurs.
Bardzo ważne jest rozumienie, że rezygnacja z JPA tu nie oznacza „persistence nie jest ważna". Oznacza „persistence jest tak ważna, że zasługuje na osobny fokus". W ścieżce po to właśnie stoi Spring Data JPA po obecnym kursie. Najpierw stabilizujesz zewnętrzną granicę aplikacji, potem uczysz się podłączać do niej warstwę danych bez niszczenia kontraktu.
Jest jeszcze jeden powód metodyczny ✋. Jeśli zbyt wcześnie zaczniesz budować API od struktury przechowywania, nabierzesz bardzo niebezpiecznego nawyku: projektowania zewnętrznego kontraktu od tabeli lub encji, a nie od scenariusza klienta. Ten kurs świadomie zadaje odwrotną kolejność. Najpierw klient, kontrakt i warstwa webowa. Potem — dane i wybory persistence.
4. Pliki wymagają osobnej pracy
Z plikami dzieje się podobna historia. Jeśli nie utrzymamy granic, projekt bardzo szybko ześlizgnie się w dziwny świat, gdzie dane binarne zaczną mieszkać w tych samych strukturach co zwykłe pola JSON. To niewygodne i dla API, i dla pamięci, i dla architektury.
Dlatego w projekcie od samego początku rozdziela się dwie encje: metadane załącznika i zawartość pliku. Metadane to część obrazu domenowego i API. Klientowi zależy na nazwie pliku, rozmiarze, typie, momencie uploadu, powiązaniu z zadaniem. Natomiast fizyczne przechowywanie bajtów to już osobna linia techniczna.
Minimalna abstrakcja przechowywania zawartości może wyglądać tak:
public interface AttachmentContentStorage {
// Zapisanie zawartości pliku w magazynie pod kluczem (klucz niekoniecznie = nazwa pliku)
void save(String key, InputStream content) throws IOException;
// Wczytanie zawartości z magazynu po kluczu i zapisanie jej do docelowego OutputStream
void load(String key, OutputStream target) throws IOException;
// Usunięcie zawartości z magazynu po kluczu
void delete(String key) throws IOException;
}
A lokalna implementacja może pracować ze zwykłym katalogiem na dysku:
public class LocalAttachmentContentStorage implements AttachmentContentStorage {
// Główny katalog, w którym fizycznie będą leżeć pliki
private final Path rootDir;
public LocalAttachmentContentStorage(Path rootDir) {
// Zależność (ścieżka) wstrzykiwana z zewnątrz, żeby nie hardkodować lokalizacji w kodzie
this.rootDir = rootDir;
}
@Override
public void save(String key, InputStream content) throws IOException {
// Na wszelki wypadek tworzymy katalog (jeśli już istnieje — błędu nie będzie)
Files.createDirectories(rootDir);
// Kopiujemy bajty ze strumienia wejściowego do pliku na dysku, zastępując plik przy ponownym uploadzie
Files.copy(content, rootDir.resolve(key), StandardCopyOption.REPLACE_EXISTING);
}
// load/delete pominięte dla zwięzłości
}
Dlaczego to przydatne już teraz? Bo z góry nie pozwalasz tematowi plików rozerwać projektu na kawałki. Kontroler nie powinien sam chodzić do systemu plików bezpośrednio. Serwis nie powinien przechowywać byte[] wewnątrz zadania. Metadane nie powinny mieszać się z fizyczną ścieżką przechowywania. Nawet jeśli infrastruktura jest uproszczona, granice pozostają dojrzałe.
5. Bez Security lepiej widać samo API 🔐
Z Security sytuacja jest bardzo podobna do JPA, tylko jeszcze bardziej wrażliwa. Gdy tylko dodasz uwierzytelnianie i autoryzację, zmienia się nie tylko wewnętrzna budowa projektu, ale i sama zewnętrzna semantyka żądań. Pojawiają się 401, 403, reguły własności, role, kontekst użytkownika, obsługa haseł, filtry, tokeny i długi łańcuch decyzji o dostępie.
To bardzo ważny temat. Ale w kursie o REST API design łatwo staje się zbyt głośny 🔊. Zamiast spokojnie omawiać model zasobów, DTO, walidację i obsługę błędów, na każdym kroku zaczynasz myśleć: kto jest teraz użytkownikiem, jak przekazywana jest jego tożsamość, dlaczego ten scenariusz jest forbidden, jak pisać testy z auth, gdzie przebiega granica między regułą biznesową a regułą bezpieczeństwa.
Dla pierwszego dużego kursu o warstwie web/API to zbyt silny czynnik rozpraszający 🚧. Dlatego projekt celowo pozostaje bez security. Nie dlatego, że security nie jest potrzebna. Lecz dlatego, że teraz przydatnie jest najpierw zobaczyć „czysty" kontrakt: zasoby, operacje, formy danych, błędy i reguły normalnego zachowania API.
To też uczciwe wobec sąsiedniego kursu. Spring Security istnieje nie jako „dodatek", lecz jako osobna dyscyplina. Gdy przyjdziesz tam już z normalnym modelem zasobów i modelem błędów, security będzie nakładać się na zrozumiałą podstawę, a nie na chaotyczny zestaw endpointów.
6. Jak ta struktura będzie kontynuowana w Data i Security 🌉
Jedną z największych zalet obecnego rozwiązania jest to, że nie jest ono ślepą uliczką. To nie jest „zabawkowa architektura, którą potem trzeba będzie wyrzucić". Wręcz przeciwnie — to taka forma projektu, która spokojnie żyje dalej.
Spójrz na tę prostą mapę:
| Teraz w kursie | Co może pojawić się później bez niszczenia warstwy webowej |
|---|---|
| InMemoryTaskRepository | JpaTaskRepository lub inny adapter persistence |
| LocalAttachmentContentStorage | S3-like storage lub inny adapter plikowy |
| brak auth | łańcuch filtrów Security, model użytkownika, autoryzacja biznesowa |
| DTO i kontrakt błędów | pozostają tą samą zewnętrzną granicą |
| controller → service → repository | pozostaje, zmieniają się wewnętrzne implementacje |
To bardzo ważny moment. Nasz kurs nie udaje, że in-memory jest lepsze od bazy, a lokalny katalog lepszy od normalnego storage. Kurs pokazuje coś innego: zewnętrzny kontrakt i granice architektoniczne można budować już teraz, nie czekając na wszystkie ciężkie technologie.
Później, gdy pojawi się warstwa danych, dobrze zbudowana warstwa web/API nie powinna pękać w szwach. Potem, gdy pojawi się security, dobrze zaprojektowane zasoby, statusy i model błędów nie powinny nagle stać się przypadkowe. Właśnie dlatego kolejność tematów w ścieżce jest taka, jaka jest: najpierw stabilizuje się granica API, potem podłączane są do niej cięższe warstwy.
7. Typowe błędy przy upraszczaniu infrastruktury 🚧
Błąd №1: „Skoro nie ma bazy, repozytorium nie jest potrzebne".
To jeden z najszybszych sposobów zamiany projektu w chaos. Przechowywanie i tak musi żyć za osobną granicą. Inaczej zaczyna rozmazywać się po serwisach i kontrolerach.
Błąd №2: trzymać dane bezpośrednio w kontrolerze, bo tak szybciej.
Szybciej — tak. Lepiej — prawie nigdy. Kontroler powinien zajmować się zadaniem web/API, a nie być jednocześnie transportem, serwisem i magazynem.
Błąd №3: próbować „trochę dodać JPA" tylko dla jednej encji.
Taki hybrydowy projekt jest prawie zawsze metodycznie gorszy od uczciwie uproszczonego. Masz od razu dwa światy: część zachowania dyktowana jest przez warstwę danych, część nie — i studentowi staje się trudniej rozróżnić, czego się właśnie uczy.
Błąd №4: przechowywać zawartość pliku bezpośrednio w modelu domenowym zadania.
Wydaje się wygodne tylko w danej chwili. Potem pojawiają się ciężkie odpowiedzi, problemy z pamięcią i mieszanie metadanych z zawartością. Osobna abstrakcja storage jest tu potrzebna nie dla urody, lecz dla zdrowia projektu.
Błąd №5: postrzegać brak Security jako „niepoważność".
To po prostu świadoma granica kursu. Tu najpierw uczy się budowania czystej i przewidywalnej granicy API. Security przyjdzie na następnym poziomie, gdy ta granica przestanie być mętna.
GO TO FULL VERSION