Frameworkurile moderne, precum Spring, accelerează dezvoltarea, dar pot dicta arhitectura și deciziile de design. Întrebarea esențială: cine controlează cu adevărat designul aplicației — noi sau frameworkul?
Un principiu fundamental este izolarea logicii de business (domeniul) de infrastructură și framework. Dacă domeniul tău depinde direct de Spring, JPA, clientul HTTP sau orice altă integrare, orice schimbare tehnologică devine dificilă, iar testarea complicată. O soluție elegantă este arhitectura hexagonală. Domeniul definește doar interfețe (porturi), iar infrastructura le implementează prin adaptoare. Domeniul nu știe nimic despre framework sau infrastructură. În continuare vom explora o parte din tehnicile concrete pe care le putem folosi pentru a implementa arhitectura hexagonală în proiectele noastre.
Folosirea excesivă a tipurilor simple (String, int. etc.) pentru date cu semnificație de business duce la erori și la cod greu de întreținut. Soluția: tipuri specializate cu validare în constructor. Astfel, codul devine mai sigur și mai ușor de testat, iar validarea reutilizabilă.
public record MerchantCategoryCode(String value) {
public MerchantCategoryCode {
Objects.requireNonNull(value,
"MCC cannot be null");
if (!value.matches("\\d{4}")) {
throw new
IllegalArgumentException(
"MCC must be exactly 4 digits");
}
}
}
O obiecție validă ar putea fi: dacă încapsulăm fiecare tip de bază într-o nouă clasă, cum va fi afectată performanța aplicației noastre? În limbaje precum Kotlin această problemă este rezolvată deja prin inline sau value classes, prin utilizarea cărora clasele precum cea de mai sus sunt șterse după compilare, păstrând toate avantajele enumerate fără a afecta performanța la runtime. Un concept similar, value objects, va fi introdus în Java prin proiectul Valhalla. Între timp considerăm că mentenabilitatea și lizibilitatea codului nostru ar trebui să fie o prioritate, iar optimizările le vom face acolo unde testele de performanță ne-o vor cere.
În migrarea noastră de la o arhitectură stratificată către cea hexagonală, vom izola domeniul nostru prin folosirea de interfețe (porturi). O capcană în care am putea pica ușor este să folosim direct CategorizedTransactionRepository ca implementare de Spring Data JPA. Este doar o interfață, nu?! Dar la o privire atentă vom observa că este strâns legată de Spring, prin moștenire și prin adnotări.
Când ne definim interfața pentru accesul bazei de date în domeniu, putem face câțiva pași suplimentari. În loc de o singură interfață cu toate funcțiile de acces, ne putem defini interfețe funcționale individuale. Astfel, vom implementa principiul de segregare a interfețelor (I din SOLID) și vom obține o multitudine de beneficii, cum ar fi: în implementarea domeniului putem folosi doar interfețele de care avem nevoie, fără dependențe inutile care duc la cuplare suplimentară; putem testa folosind expresii lambda în loc de mockuri; putem înlocui cu ușurință o implementare fără a afecta alte părți ale sistemului; facilităm refactorizări viitoare și integrarea de noi tehnologii; facilităm migrarea tehnologiilor sau a bazelor de date.
Dar încercând să implementăm aceste concepte, în special în contextul utilizării unui framework pentru injectarea dependențelor (DI), ne va îngrijora numărul mare de implementări necesare. Vom vedea în continuare că avem o alternativă.
Un exemplu relevant este interfața repository cu care am început mai sus. Pentru fiecare metodă pe care o expune putem extrage în domeniul nostru o interfață funcțională:
@FunctionalInterface
interface FindByTransactionId {
Optional
findBy(TransactionId id);
}
Similar vom avea SaveCategorizedTransaction, FindByTransactionId, FindBudgetsByCategory și FindExpenseCategoriesByClient. În implementarea serviciului nostru vom alege interacțiunile de care avem nevoie:
public class TransactionCategorizer {
public TransactionCategorizer(
SaveCategorizedTransaction saveCategorizedTr,
FindByTransactionId findByTransactionId) {/*...*/}
}
Dar, în același timp, putem folosi un singur adaptor care să implementeze toate aceste interfețe, făcând tranziția dinspre domeniu către infrastructură:
public class CategorizedTransactionRepositoryAdapter implements
SaveCategorizedTransaction,
FindByTransactionId, /*etc*/
{}
Iar folosind Spring putem injecta același adaptor de mai multe ori în mai multe puncte ale implementării noastre de business, refolosind aceeași implementare:
@Bean
public TransactionCategorizer
transactionCategorizationService(
CategorizedTransactionRepositoryAdapter
repositoryAdapter) {
return new TransactionCategorizer(
repositoryAdapter, repositoryAdapter);
}
Deși un număr mare de dependențe poate indica o problemă de design, există situații în care găsim acest lucru acceptabil, cum ar fi un REST controller care expune mai multe endpointuri de interogare a datelor. În acest caz putem aplica o soluție care va simplifica și mai mult codul, profitând de capabilitatea frameworkului de a face dependency injection pe baza tipurilor generice.
@RestController
public class CategorizedTransactionController {
public CategorizedTransactionController(
R repository) {
this.repository = repository;
}
}
În cazul de mai sus, beanul cu implementarea CategorizedTransactionRepositoryAdapter va fi injectat automat de către Spring, soluția fiind concisă și păstrând o cuplare minimă între componentele noastre.
Un mare avantaj al noului nostru design este ușurința cu care putem testa, fără a folosi mockuri:
final var service = new TransactionCategorizer(
(categorizedTransaction) ->
categorizedTransaction.withId(
new CategorizedTransactionId(1L)
),
(transactionId) -> Optional.empty()
);
În mod tradițional erorile sunt tratate ca excepții, deseori introducând probleme subtile: fluxuri de control greu de urmărit, debugging anevoios și testare problematică. Toate acestea se traduc în costuri mari și pierderi semnificative. Dar cum ar fi dacă am putea gestiona erorile explicit, transparent și sistematic? Răspunsul vine din programarea funcțională, care propune tratarea erorilor ca valori clare și explicite, integrate în logica aplicațiilor. Această abordare ne permite să transformăm radical gestionarea erorilor, făcând-o vizibilă și ușor de administrat încă din faza de dezvoltare.
În locul excepțiilor, funcțiile noastre returnează explicit rezultatele, fie ele succese sau erori. Tipuri precum Either\
sealed interface TransactionCategorizationError
permits TransactionCategoryNotFound,
MerchantCategoryLookupFailed {
record TransactionCategoryNotFound(String message)
implements TransactionCategorizationError {}
record MerchantCategoryLookupFailed(String message)
implements TransactionCategorizationError {}
}
public Either categorize(Transaction
transaction) { ... }
Frameworkurile sunt unelte, nu reguli absolute. Ține domeniul curat, definește-ți propriile interfețe și tipuri, tratează erorile ca parte din domeniu și lasă infrastructura să se adapteze la nevoile tale, nu invers. Vei avea un cod mai clar, mai ușor de testat și de întreținut, indiferent de frameworkul folosit. Iar înlocuirea unui framework sau a unei biblioteci va deveni palpabilă, nu un concept teoretic pe care nu am îndrăzni să-l punem în practică.
Principiile expuse mai sus reprezintă doar un sneak peek pentru prezentarea pe care o voi susține alături de colegul meu, Gabriel Bornea, pe 17 iunie, în cadrul hangoutului de la sediul ING Hubs din Cluj-Napoca, numit "Functional approaches to software design & concurrency". Vă puteți înregistra la eveniment aici.