CodeGym /Kursy /Spring REST & MVC /Granice funkcjonalności i storage abstraction

Granice funkcjonalności i storage abstraction

Spring REST & MVC
Poziom 9, Lekcja 4
Dostępny

1. Ryzyko „superserwisu" w projekcie

Gdy tylko projekt przekracza ramy „jednego kontrolera i jednej encji", pojawia się niebezpieczna pokusa: dalej wrzucać wszystko w jedno miejsce, bo „tak szybciej". Na krótką metę wydaje się to wygodne, ale na długą zamienia kod w kaszę, gdzie zmiany pociągają za sobą wszystko, a szukanie przyczyny buga staje się misją z kiepskim zakończeniem.

Wyobraźmy sobie, że mamy już TaskController i TaskService. Wszystko świetnie: lista zadań, jedno zadanie po id, tworzenie zadania. Ale gdy tylko dodajemy komentarze, tagi i załączniki, pojawia się pokusa: „skoro to wszystko o zadaniach, to dodajmy metody do TaskService: addComment, uploadAttachment, getAllTags, deleteAttachment…". I w tym miejscu rodzi się superserwis – klasa odpowiadająca za absolutnie wszystko, czyli w praktyce niezodpowiadająca za nic konkretnego.

Problem superserwisu nie w tym, że jest „zły według podręcznika", lecz w tym, że psuje rozumienie projektu. Otwierasz TaskService – a tam 800 linii logiki o czterech różnych kierunkach. Każda zmiana zamienia się w ryzyko: „poprawiłem tagi – a dlaczego mi się posypało ładowanie załączników?". Powód prosty: wszystko związane ze wszystkim, i system nie ma już czytelnych granic.

2. Granica funkcjonalności dla zadań, komentarzy, tagów i załączników

Granica funkcjonalności to sposób na utrzymanie projektu kompaktowym i zrozumiałym, gdy ma kilka powiązanych zasobów. To nie chodzi o „jak nazwać pakiet" ani o „jakie adnotacje postawić", lecz o pytanie: która część kodu odpowiada za który fragment zachowania systemu. Dobra granica funkcjonalności czyni kod czytelnym tak samo, jak dobrze podpisane pudełka ułatwiają przeprowadzkę.

Ważne jest uchwycenie subtelności: mamy już warstwy (controller → service → repository/storage). Granica funkcjonalności to „cięcie pionowe" według sensu, a nie „poziome" według warstw. Czyli „zadania", „komentarze", „załączniki", „tagi" – to funkcjonalności. Każda funkcjonalność ma (lub może mieć) swój serwis, swoje repozytorium i swoje modele domenowe, nawet jeśli fizycznie wszystkie klasy leżą w znanych pakietach domain.* i infrastructure.*.

Żeby utrwalić obraz, wygodnie patrzeć na to tak:

Funkcjonalność „O czym ta funkcjonalność" Główny serwis Gdzie przechowywane Specjalna infrastruktura
Tasks CRUD i stan zadań TaskService TaskRepository (na razie brak)
Comments Wspierający podźródło zadania CommentService CommentRepository (na razie brak)
Tags Lookup po tagach (bez CRUD) TagService wyliczane z zadań (na razie brak)
Attachments Metadane + zawartość załączników AttachmentService AttachmentRepository +
AttachmentStorage
storage abstraction

Zauważ, że tagi są tu celowo „małe": nie mają własnego pełnoprawnego CRUD ani ciężkiego modelu cyklu życia encji. Natomiast załączniki – odwrotnie, wymagają osobnej granicy infrastrukturalnej, bo przechowywanie zawartości binarnej to nie to samo co przechowywanie ciągów i dat.

Jeśli narysować zależności, obrazek powinien pozostać możliwie prosty (bez „strzałek w każdą stronę"):

flowchart TD
  C1[TaskController] --> S1[TaskService]
  C2[CommentController] --> S2[CommentService]
  C3[TagController] --> S3[TagService]
  C4[AttachmentController] --> S4[AttachmentService]

  S1 --> R1[TaskRepository]
  S2 --> R2[CommentRepository]
  S4 --> R3[AttachmentRepository]
  S4 --> ST[AttachmentStorage]

  R1 --> IM1[(impl in-memory)]
  R2 --> IM2[(impl in-memory)]
  R3 --> IM3[(impl in-memory)]
  ST --> IM4[(impl in-memory)]

Sens diagramu nie w tym, żeby nauczyć się strzałek, lecz żeby poczuć: każda funkcjonalność ma swój „środek ciężkości", a infrastruktura nie przesiąka do kontrolerów.

3. Rozdzielenie serwisów

Gdy developer po raz pierwszy słyszy „rozdzielaj serwisy według odpowiedzialności", często myśli: „OK, to teraz będę mieć dużo klas, a lubię mało klas". Rozumiem: też lubię, gdy można otworzyć jeden plik i poczuć się bogiem systemu. Ale nasz cel jest inny – żeby projekt był zrozumiały i rozszerzalny, a nie żeby wyglądał minimalistycznie w drzewie plików.

Zacznijmy od prostej reguły: TaskService zajmuje się wyłącznie samymi zadaniami – pobrać listę, jedno zadanie, stworzyć nowe. To wystarczy, żeby widzieć środek ciężkości funkcjonalności tasks. Ważne jest, żeby nie rozdymać tego serwisu metodami w stylu addComment, uploadAttachment czy getAllTags tylko dlatego, że wszystko to jakoś związane z zadaniem.

W tym zestawie operacji celowo nie ma ani słowa o komentarzach i załącznikach. Nie dlatego, że je „zapomnieliśmy", lecz dlatego, że utrzymujemy granicę: serwis zadań opisuje tylko to, co jest bezpośrednią operacją aplikacyjną na zadaniu.

Teraz opisujemy komentarze osobno. Niech to będzie inny serwis, nawet jeśli wewnątrz sprawdza istnienie zadania (to normalne; związek funkcjonalności nie jest zabroniony – zabronionym jest zamienianie związku w kaszę):

import java.util.List;

public interface CommentService {
    // Komentarze to podźródło zadania, więc głównym kluczem jest taskId
    List<Comment> getByTaskId(String taskId);

    // Dodanie komentarza — osobna operacja funkcjonalności Comments, a nie TaskService
    Comment add(String taskId, String text);
}

Tagi to osobna mała odpowiedzialność. Ważne, żeby jej nie przeciążyć i nie robić „mini-CRM po tagach". Potrzebujemy lookup: pobierz unikalne tagi.

import java.util.List;

public interface TagService {
    // Funkcjonalność lookup: zwróć listę unikalnych tagów, bez CRUD i cyklu życia encji Tag
    List<String> getAllTags();
}

I na koniec załączniki. Tu właśnie pojawia się szczególna myśl: załącznik to nie tylko metadane, lecz i zawartość. Dlatego serwis załączników będzie zazwyczaj zależeć od dwóch granic jednocześnie: repozytorium metadanych i storage dla treści.

import java.util.List;

public interface AttachmentService {
    // Pobranie listy metadanych załączników po zadaniu (bajtów tu nie ciągniemy)
    List<AttachmentMetadata> getByTaskId(String taskId);

    // Zapis załącznika: metadane + zawartość, ale zawartość przechowywana osobno przez storage
    AttachmentMetadata save(String taskId, String fileName, byte[] content);
}

Tak, byte[] teraz wygląda prosto. Później życie podsunie nam bardziej „strumieniowe" rozwiązania, ale na poziomie architektonicznym ważniejsze jest co innego: zawartość pliku to nie pole wewnątrz Task i nawet nie musi być wewnątrz AttachmentMetadata. Po to właśnie potrzebny jest osobny storage.

4. Funkcjonalność komentarzy: serwis i repozytorium

Komentarze często próbują „przykleić" do zadania bezpośrednio w modelu: zrobić w Task pole List<Comment> comments. W małym demo wydaje się to wygodne, ale taki model szybko pokazuje zęby. Komentarze zaczynają żyć własnym życiem: trzeba je dodawać, usuwać, pobierać osobno, i stają się samodzielnym podźródłem, choć zależnym od zadania.

Dlatego wewnątrz projektu trzymamy komentarze jako osobny model domenowy. Minimalny Comment może wyglądać tak:

import java.time.Instant;

public class Comment {
    // Identyfikator komentarza (osobny od taskId)
    private final String id;

    // Związek z zadaniem przez id — to normalny model podźródła
    private final String taskId;

    // Tekst komentarza — dane domenowe, a nie infrastruktura
    private final String text;

    // Czas utworzenia przydatny do sortowania/audytu
    private final Instant createdAt;

    public Comment(String id, String taskId, String text, Instant createdAt) {
        this.id = id;
        this.taskId = taskId;
        this.text = text;
        this.createdAt = createdAt;
    }
}

Zwróć uwagę: komentarz zna taskId, bo jest podźródłem. Ale komentarz nie musi być „wewnątrz zadania" jako lista. Związek może być przez id – to normalne i często nawet wygodniejsze.

Dalej stosujemy tę samą dyscyplinę co przy zadaniach: komentarze mają repozytorium. Repozytorium to przechowywanie/pobieranie, a nie web i nie „dozwolone/niedozwolone".

import java.util.List;

public interface CommentRepository {
    List<Comment> findByTaskId(String taskId);
    Comment save(Comment comment);
}

Teraz ważna kwestia: CommentService prawie zawsze musi sprawdzić, czy zadanie istnieje. Ale można to robić różnie. Najprostszy sposób – dodać do TaskRepository metodę existsById. Brzmi nudno, ale utrzymuje zależności prostymi i bez zbędnej magii.

public interface TaskRepository {
    boolean existsById(String taskId);
}

I dalej serwis komentarzy może działać schludnie, nie zamieniając się w „drugi TaskService":

import java.time.Instant;
import java.util.UUID;
import org.springframework.stereotype.Service;

@Service
public class DefaultCommentService implements CommentService {
    private final TaskRepository taskRepository;
    private final CommentRepository commentRepository;

    public DefaultCommentService(TaskRepository taskRepository, CommentRepository commentRepository) {
        this.taskRepository = taskRepository;
        this.commentRepository = commentRepository;
    }

    public Comment add(String taskId, String text) {
        // Walidacja na poziomie serwisu aplikacyjnego: komentarza nie można dodać do nieistniejącego zadania
        if (!taskRepository.existsById(taskId)) throw new IllegalArgumentException("Task not found");

        // Generowanie id i ustawienie czasu — część operacji aplikacyjnej, a nie kontrolera
        return commentRepository.save(new Comment(UUID.randomUUID().toString(), taskId, text, Instant.now()));
    }
}

Tak, tu na razie IllegalArgumentException. To nie finalny model błędów, ale dziś mówimy o granicach funkcjonalności, a nie o ładnym error response. Ważne, że odpowiedzialność jest rozdzielona: sprawdzanie istnienia robimy w serwisie, przechowywanie w repozytorium, a kontroler potem po prostu wywoła serwis.

5. Tagi jako funkcjonalność lookup

Tagi to klasyczny przykład, gdzie łatwo wpaść w nadmierną złożoność przez perfekcjonizm. Można zrobić TagController, TagService, TagRepository, Tag jako pełnoprawną encję, dodać CRUD, dodać „zmianę nazwy tagu"… a potem stwierdzić, że buduje się połowę Jiry, podczas gdy chciało się edukacyjne API. Dlatego trzymamy tagi jako wartości wewnątrz zadań i robimy tylko lookup.

Technicznie oznacza to: żadnych osobnych kolekcji in-memory „dla tagów" jako źródła prawdy. Prawdą są zadania. A TagService to mała obliczeniowa funkcjonalność zbierająca unikalne wartości. W tym miejscu bardzo ważne jest pisanie kodu nie „jak na olimpiadzie ze Stream API", lecz czytelnie.

Załóżmy, że w modelu Task jest metoda getTags zwracająca listę ciągów. Wtedy TagService może wyglądać tak:

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import org.springframework.stereotype.Service;

@Service
public class DefaultTagService implements TagService {
    private final TaskRepository taskRepository;

    public DefaultTagService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    public List<String> getAllTags() {
        Set<String> unique = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
        for (Task task : taskRepository.findAll()) unique.addAll(task.getTags());
        return new ArrayList<>(unique);
    }
}

Tu są dwa ważne sygnały architektoniczne. Po pierwsze, lookup tagów zależy od zadań, i to normalne: tagi nie żyją same z siebie, są pochodne. Po drugie, od razu robimy wynik deterministycznym (przez TreeSet z sortowaniem bez uwzględniania wielkości liter), żeby API zawsze zwracało przewidywalną listę, a nie „jak przypadkowo wyszło w pamięci".

W ten sposób tagi pozostają małą funkcjonalnością, nie rozdymają modelu i nie zarażają TaskService zbędnymi metodami.

6. Załączniki: metadane w domenie, treść w infrastrukturze

Przy załącznikach pojawia się szczególna pułapka: plik ma „dane o pliku" (nazwa, typ, rozmiar, kiedy przesłano) i ma „sam plik" (byte[]). Jeśli wymieszać to w jedną encję, szybko zaczyna się robić dziwne rzeczy, jak przechowywanie byte[] wewnątrz modelu domenowego i przekazywanie go wszędzie. A potem dziwimy się, dlaczego API stało się wolne i dlaczego logi nagle ważą jak mały serial.

Dlatego rozdzielamy dwa pojęcia. Attachment metadata – to model domenowy, bo jest częścią sensu systemu: chcemy pokazywać, że zadanie ma załącznik, chcemy wyświetlać listę załączników i ich właściwości. Attachment content – to infrastruktura, bo bajty można przechowywać na wiele sposobów, i nie powinno to wpływać na warstwę web ani model domenowy.

Minimalny domenowy model metadanych może wyglądać tak:

import java.time.Instant;

public class AttachmentMetadata {
    private final String id;
    private final String taskId;
    private final String originalFileName;
    private final long size;
    private final Instant uploadedAt;

    public AttachmentMetadata(String id, String taskId, String originalFileName, long size, Instant uploadedAt) {
        this.id = id;
        this.taskId = taskId;
        this.originalFileName = originalFileName;
        this.size = size;
        this.uploadedAt = uploadedAt;
    }
}

Zwróć uwagę, że nie ma tu byte[] content. Celowo nie ciągniemy zawartości do warstwy domenowej, nawet jeśli „na razie to in-memory". Powód jest prosty: jeśli teraz pozwolimy sobie na mieszanie, zostanie to na zawsze (i będzie żyło w kodzie jako artefakt pierwszych dni).

Do metadanych potrzebne jest repozytorium – osobne od zadań i komentarzy. Mieszanie wszystkich encji projektu w jeden Map<String, Object> to jak trzymanie widelców, skarpetek i dokumentów w jednym pudełku „różne". Pudełko jedno, ale radości z tego mało.

import java.util.List;

public interface AttachmentRepository {
    List<AttachmentMetadata> findByTaskId(String taskId);
    AttachmentMetadata save(AttachmentMetadata metadata);
}

Teraz mamy miejsce, gdzie żyją dane o załącznikach, i to nie zależy od tego, gdzie leży zawartość.

7. Storage abstraction: AttachmentStorage i implementacja in-memory

Storage abstraction to „kontrakt" na przechowywanie zawartości pliku. Ważne, że to nie repozytorium modelu domenowego, lecz właśnie granica infrastrukturalna: serwis wie, że może zapisać/załadować/usunąć zawartość po kluczu, ale nie wie, gdzie ona leży. To może być system plików, pamięć, cokolwiek – serwis nie powinien się tym przejmować.

Interfejs robimy mały i uczciwy. Na razie wystarczą nam trzy operacje:

public interface AttachmentStorage {
    void save(String attachmentId, byte[] content);
    byte[] load(String attachmentId);
    void delete(String attachmentId);
}

Tak, tu byte[] to uproszczenie, ale idea architektoniczna przez to się nie psuje: zawartość przechowywana osobno, przez interfejs. To już ogromny krok w porównaniu z wariantem „wcisnąć bytes wewnątrz Task".

Żeby projekt się składał i żebyśmy mogli żyć bez zewnętrznych zależności, robimy prostą implementację in-memory. Może znajdować się w infrastructure.storage obok interfejsu:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Repository;

@Repository
public class InMemoryAttachmentStorage implements AttachmentStorage {
    // Przechowujemy zawartość osobno od modeli domenowych; ConcurrentHashMap — dla bezpieczeństwa wątkowego
    private final Map<String, byte[]> data = new ConcurrentHashMap<>();

    // Zapis treści po id załącznika: serwis nie musi wiedzieć, gdzie dokładnie to leży
    public void save(String attachmentId, byte[] content) { data.put(attachmentId, content); }

    // Załadowanie może zwrócić null, jeśli pliku nie ma — ważne uwzględnić to w serwisie/kontrakcie
    public byte[] load(String attachmentId) { return data.get(attachmentId); }

    // Usunięcie treści — osobna operacja infrastrukturalna
    public void delete(String attachmentId) { data.remove(attachmentId); }
}

Teraz składamy to w serwisie załączników. Serwis będzie orkiestrować dwa działania: zapisać metadane i zapisać treść. Właśnie tu pojawia się „sklejenie funkcjonalności", a nie w kontrolerze.

import java.time.Instant;
import java.util.UUID;
import org.springframework.stereotype.Service;

@Service
public class DefaultAttachmentService implements AttachmentService {
    private final AttachmentRepository attachmentRepository;
    private final AttachmentStorage attachmentStorage;

    public DefaultAttachmentService(AttachmentRepository attachmentRepository, AttachmentStorage attachmentStorage) {
        this.attachmentRepository = attachmentRepository;
        this.attachmentStorage = attachmentStorage;
    }

    public AttachmentMetadata save(String taskId, String fileName, byte[] content) {
        // id — klucz łączący metadane i zawartość binarną
        String id = UUID.randomUUID().toString();

        // Zawartość zapisujemy przez storage abstraction, żeby infrastruktura nie przeciekała na zewnątrz
        attachmentStorage.save(id, content);

        // Metadane zapisujemy osobno (repozytorium danych domenowych)
        return attachmentRepository.save(new AttachmentMetadata(id, taskId, fileName, content.length, Instant.now()));
    }
}

Serwis wyszedł krótki i zrozumiały: odpowiada za operację aplikacyjną „zapisz załącznik", a szczegóły przechowywania treści ukryte są za AttachmentStorage.

8. Kontrolery: cienka warstwa web

Gdy architektura jest złożona poprawnie, kontroler staje się bardzo nudny. I to komplement. Nudny kontroler oznacza, że robi swoją robotę: przyjmuje dane wejściowe i deleguje operację aplikacyjną do serwisu. Nie wie, gdzie leży Map, jak zorganizowane jest przechowywanie komentarzy, i na pewno nie powinien wiedzieć, jak zapisywane są bajty załącznika.

Nawet przy różnej formie wejścia i odpowiedzi reguła się nie zmienia: kontroler pozostaje cienkim punktem wejścia, a storage nie przecieka na zewnątrz. Dlatego tu patrzymy nie na format żądania, lecz na kierunek wywołania.

Na przykład kontroler tagów – prawie zawsze to jedna mała metoda plus wstrzyknięcie serwisu:

import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TagController {
    private final TagService tagService;

    public TagController(TagService tagService) { this.tagService = tagService; }

    @GetMapping("/api/v1/tags")
    public List<String> getAllTags() { return tagService.getAllTags(); }
}

Kontroler komentarzy analogicznie zależy tylko od CommentService. Nie sięga bezpośrednio do CommentRepository i nie zagląda do TaskRepository.

import java.util.List;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/tasks/{taskId}/comments")
public class CommentController {
    private final CommentService commentService;

    public CommentController(CommentService commentService) { this.commentService = commentService; }

    @GetMapping
    public List<Comment> list(@PathVariable String taskId) { return commentService.getByTaskId(taskId); }
}

I zasadniczy punkt dotyczący załączników: kontroler załączników nie powinien widzieć AttachmentStorage w ogóle. Pracuje przez AttachmentService, bo operacja „zapisz załącznik" jest aplikacyjna, a nie infrastrukturalna.

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/tasks/{taskId}/attachments")
public class AttachmentController {
    private final AttachmentService attachmentService;

    public AttachmentController(AttachmentService attachmentService) {
        this.attachmentService = attachmentService;
    }
}

Dalej kontroler przyjmuje dane żądania upload w formie potrzebnej API i po prostu wywołuje attachmentService.save(...). Dla tej lekcji ważny jest właśnie ten handoff: storage pozostaje ukryty za serwisem, a kontroler nie zamienia się w przewodnik do AttachmentStorage.

Taki szkielet wytrzymuje zarówno zmianę zewnętrznego API, jak i zmianę sposobu przechowywania – osobno.

9. Typowe błędy przy granicach funkcjonalności

Błąd nr 1: „TaskService musi umieć wszystko, bo wszystko związane jest z zadaniem".
Tak rodzi się superserwis, który umie listę zadań, tworzenie zadań, dodawanie komentarzy, obliczanie tagów, zarządzanie załącznikami i, jeśli się nie zatrzymać na czas, „synchronizację z czasem na serwerze". Problem w tym, że taka klasa przestaje się czytać, a każdy bug pociąga za sobą zbyt wiele efektów ubocznych.

Błąd nr 2: bezpośredni dostęp kontrolera do repozytorium lub storage.
Wygląda to jak „przyspieszenie developmentu": po co serwis, jeśli można z kontrolera od razu zrobić repo.save(...)? Ale potem zaczynasz kopiować ten sam kawałek logiki po różnych kontrolerach, a następnie dziwisz się, że „w jednym miejscu sprawdzenie jest, a w drugim zapomniano". Kontroler powinien znać tylko operacje aplikacyjne serwisu.

Błąd nr 3: przechowywanie zawartości załącznika wewnątrz Task lub AttachmentMetadata.
Gdy tylko w modelu domenowym pojawia się byte[], dostajemy ryzyko przypadkowej serializacji, ryzyko przypadkowego logowania, problemy z pamięcią i bardzo dziwne zależności (np. nagle metoda „pobierz zadanie" zaczyna zależeć od tego, czy ładowałeś plik). Zawartość – to osobna granica infrastrukturalna.

Błąd nr 4: próba zrobienia tagów pełnoprawną encją z osobnym CRUD bez powodu.
To klasyczny over-engineering: „skoro jest endpoint, to potrzebne repozytorium, potrzebna encja, potrzebny osobny podsystem". W naszym projekcie tagi to wartości wewnątrz zadań, a lookup po tagach to odpowiedź obliczeniowa. Im mniej niepotrzebnych encji, tym łatwiej utrzymać sens projektu.

Błąd nr 5: jedno repozytorium „na wszystko" i jedna Map<String, Object> „na wszystko".
Czasem chce się zrobić Map<String, Object> storage i wkładać tam i zadania, i komentarze, i załączniki. To szybko zamienia kod w festiwal castingu ((Task), (Comment)) i psuje granice odpowiedzialności. Lepiej kilka małych repozytoriów z czytelnymi metodami niż jedno duże pudełko „różne".

1
Zadanie
Spring REST & MVC, poziom 9, lekcja 4
Niedostępne
Oddzielne serwisy dla ksi\u0105\u017cek i opinii
Oddzielne serwisy dla ksi\u0105\u017cek i opinii
1
Zadanie
Spring REST & MVC, poziom 9, lekcja 4
Niedostępne
AttachmentStorage dla metadanych i zawartości tekstowej
AttachmentStorage dla metadanych i zawartości tekstowej
1
Ankieta/quiz
Architektura projektu, poziom 9, lekcja 4
Niedostępny
Architektura projektu
Warstwy i zależności projektu
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION