Przekonanie o wystarczalności wzorców projektowych oraz zbioru zasad programistycznych kończy się powstaniem kolekcji trochę bardziej skomplikowanych CRUDów. O tym, że architektura całego systemu jest ważna, przekonywał często Sławomir Sobótka. Krytykując przerośnięte CRUDy komentarzem „Encje na twarz i pchasz” wskazywał na bezwstydność w pomijaniu modelu domeny w rozwoju oprogramowania. Ponieważ samemu zagłębiam się w ten temat, postanowiłem rozpocząć serię postów od analizy architektury kilku moich projektów komercyjnych.
Null-architecture
Pierwszy na tapet powędruje projekt, w którym trudno powiedzieć o istnieniu jakiejś architektury. Oczywiście, przy projekcie było kilka osób w roli „Architekta”, ale obecność architekta na istnienie architektury ma taki wpływ jak obecność proboszcza na pobożność parafii. Wydaje się, że powinno to być powiązane, ale jednak nie.
Wyznaczniki null-architecture:
- kod podzielony na pakiety intuicyjnie – częściowo tematycznie, częściowo użytecznie
- testy się zdarzają
- mocno wymuszany checkstyle
- ignorowane ostrzeżenia kompilatora i statycznej analizy kodu
- wynik Sonara wskazujący na technical debt większy niż czas trwania projektu
- update dowolnej zależności/wersji Javy to misja na Marsa
- powtarzające się wielopoziomowo null-checki
Drivery:
- trzeba było napisać serwer, a każdemu się wydawało, że jak serwer to Java
- Java jest podobna do C++, mamy dużo ludzi od C++
Konsekwencją luźnego podejścia do architektury jest to, że z czasem codebase się rozjeżdża, a piękne hasła zostają wypaczone. Pierwszym przykładem jest programowanie reaktywne, które we wspomnianym projekcie miało uprościć i usprawnić wielowątkowość. Zatrzymajmy w myślach to słowo „uprościć” przypominając sobie czasy sprzed Javy 8, z RxJavą 1.x. Jeżeli jesteście osobami zbyt młodymi, żeby to pamiętać, wyglądało to tak:
request
// krok 1: walidacja i zapis do bazy
.flatMap(new Func1<String, Observable<String>>() {
@Override
public Observable<String> call(final String input) {
return saveToDatabase(input)
.map(new Func1<String, String>() {
@Override
public String call(String saved) {
return "Zapisano: " + saved;
}
});
}
})
// krok 2: przetwarzanie danych
.flatMap(new Func1<String, Observable<String>>() {
@Override
public Observable<String> call(final String saved) {
return processData(saved)
.map(new Func1<String, String>() {
@Override
public String call(String processed) {
return "Przetworzono: " + processed;
}
});
}
})
// krok 3: odczyt z bazy
.flatMap(new Func1<String, Observable<String>>() {
@Override
public Observable<String> call(final String processed) {
return readFromDatabase(processed)
.map(new Func1<String, String>() {
@Override
public String call(String loaded) {
return "Odczytano z bazy: " + loaded;
}
});
}
})
// krok 4: odpowiedź do klienta
.subscribe(new Action1<String>() {
@Override
public void call(String response) {
System.out.println("Odpowiedź do klienta: " + response);
}
});
To, szanowni młodzi czytelnicy, jest callback hell. Od razu zbiję intuicyjne „no ale przecież można lambdy!” – można, ale od Javy 8.
Drugą bolączką używania haseł zamiast architektury jest zasada „nie chcemy frameworków, bo komplikują projekt”. I teraz przyznam jak najbardziej rację temu, że projekty używające frameworków są skomplikowane. Ale zazwyczaj też wynika to z tego, że stosowanie frameworków wynika ze stopnia skomplikowania projektu. Sprzeciw wobec frameworków „dla zasady” sprawia, że projekt odkrywa koło na nowo, ignoruje sprawdzone wzorce i często kończy z własnym mini-frameworkiem, trudniejszym i droższym w utrzymaniu niż dostosowanie się do istniejącego rozwiązania. To dostosowanie się również jest pewnego rodzaju zaletą, ponieważ wymusza trzymanie się jakiejś struktury, która zawsze jest lepsza niż żadna.
Architektura korporacyjna
Przypomnijmy sobie na chwilę, czym jest (a przynajmniej chciała być, jak powstawała) Java. Java to technologia, która pozwala na modelowanie obiektów i procesów biznesowych bezpośrednio w kodzie. Pierwsze projekty powstawały w duchu programowania obiektowego modelującego biznes, ale z wieloma przypadkami przeplatania reguł biznesowych z warstwą prezentacji czy przechowywania.
Najczystszym przykładem architektury korporacyjnej są klasy typu Manager (a będące de facto Mediatorami), które są managerami w naszej architekturze korporacyjnej.
Jak wyglądałby wtedy prosty proces zapisu posta na forum? Jakoś tak:
public void savePost(String username, String rawContent) {
logger.info("Użytkownik " + username + " wysyła post przez formularz...");
if (rawContent == null || rawContent.trim().length() == 0) {
throw new IllegalArgumentException("Post nie może być pusty!");
}
if (username == null || username.trim().length() == 0) {
throw new IllegalArgumentException("Brak nazwy użytkownika!");
}
String content = sanitize(rawContent);
Connection conn = null;
PreparedStatement stmt = null;
try {
conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/forum", "root", "secret");
stmt = conn.prepareStatement("INSERT INTO posts (username, content) VALUES (?, ?)");
stmt.setString(1, username);
stmt.setString(2, content);
stmt.executeUpdate();
logger.info("Post został zapisany pomyślnie w bazie!");
} catch (SQLException e) {
logger.error("Błąd podczas zapisu do bazy: " + e.getMessage(), e);
throw new RuntimeException("Nie udało się zapisać posta!");
} finally {
try {
if (stmt != null) stmt.close();
if (conn != null) conn.close();
} catch (SQLException ignore) {
logger.warn("Błąd przy zamykaniu zasobów JDBC", ignore);
}
}
Przyznaję, że ten przykład zahacza o ekstremum – część bazodanowa pewnie byłaby wyodrębniona jak nie do oddzielnej metody to do oddzielnej klasy. Mapowanie użytkownika i zawartości posta niekoniecznie natomiast odbyłoby się do klasy biznesowej – zjawisko częste ze względu na chęć „uproszczenia” kodu i brak dostępnych dobrych rozwiązań JPA w tamtych czasach.
Mogłoby się wydawać, że to nie jest najgorsze, na co może być stać architekturę korporacyjną. Należy sobie jednak uzmysłowić, że to nie są jeszcze czasy RESTa i SOAPa (a nawet AJAXa). Cała obsługa wejścia/wyjścia odbywała się często przez servlety JSP często wspierane przez strutsa. Logika biznesowa również i w te miejsca potrafiła wyciec. Wyobraźmy sobie niegroźnie wyglądające menu akcji:
<%@ page import="java.util.*" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head><title>Panel akcji</title></head>
<body>
<h2>Wybierz akcję</h2>
<%
String state = request.getParameter("state");
List<String> actions = new ArrayList<String>();
if ("guest".equals(state)) {
actions.add("Zaloguj się");
actions.add("Zarejestruj konto");
} else if ("user".equals(state)) {
actions.add("Dodaj post");
actions.add("Edytuj swój profil");
} else if ("admin".equals(state)) {
actions.add("Zarządzaj użytkownikami");
actions.add("Moderuj posty");
actions.add("Przeglądaj logi systemowe");
} else {
actions.add("Nieznany stan – brak akcji");
}
for (String action : actions) {
out.println("<p><a href='#'>" + action + "</a></p>");
}
%>
</body>
</html>
Mieszanie warstwy prezentacji z logiką biznesową sprawia, że trudniej nią zarządzać. Próbą dla takiego rozwiązania jest na przykład dodanie dodatkowego punktu dostępu (np. przez REST). Logika zaimplementowana w warstwie prezentacji musi być odtworzona w nowym miejscu.
No-Model Sometimes-View Usually-Controller
Wyraźniejszą separację MVC przyniosło spopularyzowanie się aplikacji webowych typu SPA komunikujących się z RESTowym backendem. Niestety, rozwinęło się i spopularyzowało korzystanie z rozwiązań opartych na JPA. Świat Javy tak bardzo rzucił się na encje bazodanowe obsługiwane przez JPA, że projekty wymieniły model domenowy aplikacji na model bazodanowy. Ponieważ logika biznesowa nie mogła być już wypchnięta na warstwę prezentacji (która była niezależną aplikacją) a w celu ułatwienia pracy z JPA model zazwyczaj był anemiczny, całość logiki lądowała w ZróbWszystkoServiceImpl.java. Zademonstrujmy to na przykładzie obciążenia rachunku bankowego:
@POST
@Path("/account/{accountId}/withdraw")
@Consumes("application/json")
@Produces("application/json")
public AccountDto withdraw(@PathParam("accountId") UUID accountId, WithdrawRequest request) {
var result = accountService.withdraw(accountId, request.getAmount());
return AccountMapper.map(result);
}
public Account withdraw(UUID accountId, float amount) {
var account = accountRepository.get(accountId);
if (account.getCredit() > amount) {
account.setCredit(account.getCredit() - amount);
return accountRepository.update(account);
}
amount -= account.getCredit();
account.setCredit(0);
if (amount + account.getDebit() > account.getLimit()) {
throw new InsufficientLimitException();
}
account.setDebit(account.getDebit() + amount);
return accountRepository.update(account);
}
public class Account {
private UUID id;
private float credit;
private float debit;
private float limit;
// getters and setters
}
Można by się w tym przykładzie zastanawiać, czy mapowanie obiektu do/z DTO powinno się odbywać w kontrolerze czy serwisie. A jeżeli można się zastanawiać, oznacza to, że brakuje nam dodatkowej warstwy będącej modelem domenowym.
Trochę apologetyki
Pomimo wielu słów krytyki uważam, że stan tych projektów był możliwie najlepszy jak na swoje czasy. Dzisiaj można łatwo krytykować nieczytelny kod, który dzisiaj zostałby uproszczony np. lambdami. Brak świadomości oraz pomysłu na to, w jaki sposób rozwijać oprogramowanie w relatywnie nowej technologii doprowadziło do pojawienia się czegoś, co dzisiaj jest dla nas antywzorcem.
Wbrew pozorom programiści z tamtych czasów, zakochani we wzorcach projektowych, mogą dziś przeżyć swój renesans. Dobrze znają język, który pozwala formułować jasne instrukcje dla narzędzi generujących kod, takich jak Copilot czy Cursor.
Programista jutra będzie korzystał z lekcji przeszłości. Dzięki nim stanie się skutecznym operatorem inteligentnych generatorów kodu, koncentrując się przede wszystkim na architekturze i wzorcach.

Dodaj komentarz