Nie używaj Long i String jako identyfikatorów

Typy proste jako identyfikatory obiektów to nie jest najlepszy pomysł. To działa, jednak można to zrobić lepiej niewielkim nakładem pracy.

Prawdę mówiąc, jedyne miejsce, w którym ma to rację bytu to encje bazodanowe w ORM którego używasz.

Jeżeli nie typy proste, to co w takim razie?

Stwórz własne klasy na identyfikatory. Każdy rodzaj identyfikatora będzie miał własną klasę.

Na przykład tak (wykorzystując Lombok):

@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@EqualsAndHashCode
@ToString
public class EventId {

    private final String value;

    public static EventId of(String value) {
        return new EventId(value);
    }

    public String asString() {
        return value;
    }
}

Mamy klasę EventId, a w niej:

  • prywatny konstruktor ze źródłowym natywnym identyfikatorem – w tym przypadku String,
  • wewnętrzne, nie mutowalne pole na przechowanie natywnego identyfikatora,
  • publiczną, statyczną metodę (of(String)) tworzącą nasz identyfikator na podstawie jego natywnej wartości,
  • publiczną metodę pozwalającą przekształcić identyikator do natywnej wartości (asString())
  • do tego standardowe metody equals i hashcode na potrzeby porównywania,
  • opcjonalnie, toString aby móc ładnie reprezentować obiekt w logach

Jak widzisz, nie jest to dużo pracy, tylko po co to wszytko? Już tłumaczę.

Czytelność

Spójrz na dwa poniższe przykłady, który jest dla Ciebie bardziej czytelny?

public void subscribe(String userId, String eventId) {
    eventStorage.subscribe(eventId, userId);
}
public void subscribe(UserId userId, EventId eventId) {
    eventStorage.subscribe(eventId, userId);
}

Dla mnie zdecydowanie wygrywa druga opcja. Jeszcze więcej zyskuje od strony użycia tych metod, IDE jest w stanie dużo łatwiej mi podpowiedzieć, którą wartość powinienem przekazać.

Dodatkowo jestem zabezpieczony przed pomyleniem kolejności argumentów. W pierwszym przypadku, jak się pomylę, to dopiero testy mają szanse mi wskazać błąd.

Walidacja

W metodach tworzących instancję naszego identyfikatora możemy zawrzeć logikę sprawdzającą poprawność, która zabezpieczy nas przed złym formatem identyfikatora. Czyli możemy zastosować podejście Fail-fast, czyli wykrywanie błędów i niepoprawności w działaniu jak najszybciej.

Własna logika generowania

Jeżeli mamy taką potrzebę, to możemy zawrzeć w klasie dowolnie skomplikowaną logikę generowania wartości identyfikatora. Możemy oprzeć go o inne identyfikatory, tak aby wygenerować jakąś formę identyfikatora-childa w stosunku do innego i w ten sposób zbudować hierarchię już na poziomie identyfikatorów.

Wygodniejszy refactor

Kolejna sprawa to dużo wygodniejszy refactor. Opieramy się ta klasach, które są unikalne i zawsze znaczą to samo. Jesteśmy w stanie bardzo łatwo namierzyć wszystkie użycia naszego identyfikatora i dokonać zmian tak jak z każdą inną klasą. Nie musimy się bawić w Find and Replace na podstawie nazw zmiennych i żmudnego przeglądania każdego kawałka znalezionego kodu.

A czy mogę wystawić takie typy na REST endpoincie?

Oczywiście, jednak trzeba wykonać tutaj jeszcze kilka rzeczy.

Pierwsze co, to po prostu używamy tych typów na naszym Controllerze.

@PostMapping("/{id}/subscribe")
public void subscribe(@PathVariable("id") EventId eventId, @RequestHeader(HEADER_USER_ID) UserId userId) {
    eventService.subscribe(userId, eventId);
}

Następne co musimy zrobić, to nauczyć Springa je tworzyć na podstawie wartości tekstowych, które trafiają na endpoint w postaci JSONa w body, @PathVariable czy też @RequestParam w URLu.

Do tego przede wszystkim potrzebujemy Converter, który będzie konwertował String na EventId:

public class EventIdFromStringConverter implements Converter<String, EventId> {
    @Override
    public EventId convert(String value) {
        return EventId.of(value);
    }
}

Tak utworzony konwerter (i wszystkie inne konwertery, jakie utworzymy) musimy jeszcze zarejestrować w konfiguracji WebMvc Springa – czyli konfiguracji warstwy REST.

Aby to zrobić, potrzebujemy klasy konfiguracji, rozszerzającej WebMvcConfigurer i w niej dodajemy własną implementację metody addFormaters, w której dodajemy nasz Convert do registry.

Na przykład w taki sposób:

@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new EventIdFromStringConverter());
        registry.addConverter(new EventIdToStringConverter());

        registry.addConverter(new UserIdFromStringConverter());
    }

}

No i to wszystko, nasze identyfikatory zostaną powołane do życia automatycznie i przekazane dalej. Jeśli tworzenie identyfikatorów będzie zawierało logikę walidacyjną, to od razu na etapie Convertera możemy to obsłużyć wyjątkiem i zwrócić z naszego endpointa błąd 400 Bad request.

Dodatkowe materiały

  • GitHub – repozytorium z aplikacją wykorzystującą własne obiekty jako identyfikatory