Gdybym miał polecić sobie z przeszłości jeden wzorzec do nauki, wybrałbym Value Object. Mimo to, że to dość prosty koncept, w mojej karierze przez długi okres spotykałem się z projektami, które go nie wykorzystywały. Dopiero po kilku latach doświadczenia miałem okazję z nim się zapoznać. Być może przyczyną tego jest fakt, że Value Object jest building blockiem z Domain-Driven Design, a w początkowej swojej karierze nie miałem okazji pracować w tym podejściu. Nic jednak nie stoi na przeszkodzie, aby stosować wzorzec Value Object w projektach, które są tworzone bez DDD. W poniższym wpisie postaram się przekonać Cię do wykorzystania tego wzorca.💪
- Czym jest Value Object (VO)?
- Cechy Value Object
- Jaki problemy rozwiązuje Value Object?
3.1 Duplikacja walidacji
3.2 Obsesja typów prymitywnych
3.3 Brak kontroli nad metodami - Persystencja Value Objects
- Value Object vs Encja
- Identyfikator jako Value Object
- Podsumowanie
Czym jest Value Object (VO)?
Posiadając banknot o nominale 200 zł, zasadniczo interesuje nas wyłącznie jego wartość. Nie znam nikogo, kto twierdzi, że Kazimierz III Wielki jest fajniejszy niż Zygmunt I, dlatego chce zamienić się banknotem 200 zł na 50 zł. Na co dzień oceniamy banknot przez pryzmat jego wartości. Jeśli w kodziku też mamy taki byt reprezentowany przez wartość, to znaczy, że jest to kandydat właśnie na Value Object. Pieniądz jest akurat dobrym przykładem, bo jeśli modelujemy go w kodzie, zazwyczaj będzie on idealnym kandydatem na VO.
Cechy Value Object
Niemutowalność (ang. immutability) – VO nie podlega modyfikacjom. Przypomnijmy sobie banknot z życia codziennego. Nie możemy dopisać markerem zera na końcu do banknotu 10 zł, przekształcając go w ten sposób w banknot 100 zł. Nie jesteśmy w stanie zmodyfikować tego banknotu. Może to trochę dziwne porównanie, ale łatwo zapamiętać :). VO działa podobnie jak klasa String
w Javie — jeśli chcemy zmienić wartość, tworzymy nowy obiekt.
Tożsamość przez wartość – Tożsamość Value Objectu jest określana na podstawie wartości, które przechowuje, a nie na podstawie unikalnego identyfikatora. Dwa VO uznaje się za równe, jeśli mają takie same wartości. W życiu codziennym banknot o nominale 200 zł jest równy innemu banknotowi o nominale 200 zł.
Samoweryfikowalność (ang. self-validation) – Każdy utworzony VO jest od samego początku „bezpieczny” do użycia, gwarantując swoją poprawność już na etapie tworzenia. Zazwyczaj osiąga się to poprzez walidację przeprowadzaną w konstruktorze. Dzięki temu nie musimy już w innych miejscach kodu sprawdzać poprawności tego obiektu.
Łatwość testowania – Value Objects są łatwe do testowania, ponieważ nie posiadają zewnętrznych zależności. Testy jednostkowe dla takich obiektów są zazwyczaj proste do napisania i utrzymania.
Jaki problemy rozwiązuje Value Object?
Problemów, które rozwiązuje wykorzystanie Value Objects, jest wiele. Poniżej opiszę trzy z nich. Mam nadzieję, że one wystarczą, by Cię przekonać do korzystania z VO, jeśli jeszcze tego nie robisz.
Jeszcze jedna sprawa, zanim ruszymy z kopyta. W Value Object pola powinny być finalne. Jednakże może to stanowić problem, gdy chcemy zapisać taki obiekt w bazie danych przy użyciu JPA. Chociaż nie powinniśmy postrzegać Value Objects przez pryzmat persystencji, jest to kwestia, która często pojawia się w dyskusjach i budzi wiele pytań. Dlatego przedstawione obiekty typu Value Object zostały zaprezentowane w kontekście zapisywania ich w bazie danych z wykorzystaniem JPA, aby zilustrować, jak można to osiągnąć. Szczegółowe wyjaśnienia na ten temat można znaleźć w sekcji Persystencja Value Objects.
1. Duplikacja walidacji
Przyjmijmy, że w naszym systemie wprowadzamy funkcjonalność umożliwiającą skorzystanie z nagrody. Z nagrody można będzie skorzystać w określonym czasie, płacąc za nią jakąś kwotę.
W większości aplikacji, które widziałem, tworzenie nagrody wyglądałoby mniej więcej tak:
@Entity
class Reward extends BaseEntity {
private String name;
private Integer cost;
private LocalDate startDate;
private LocalDate endDate;
Reward(String name, Integer cost, LocalDate startDate, LocalDate endDate) {
this.name = name;
this.cost = cost;
this.startDate = startDate;
this.endDate = endDate;
}
//etc...
@Service
class RewardService {
//injections
public Reward createReward(RewardDto rewardDto) {
if (rewardDto.getStartDate().isBefore(LocalDate.now())) {
throw new IllegalArgumentException("Start date must be from the future");
}
if (rewardDto.getEndDate().isBefore(rewardDto.getStartDate())) {
throw new IllegalArgumentException("Start date must be before end date");
}
Reward reward = new Reward(rewardDto.getName(), rewardDto.getCost(),
rewardDto.getStartDate(), rewardDto.getEndDate());
return rewardRepository.save(reward);
}
//other methods
Pewnie za chwilę pojawiłaby się kolejna metoda do modyfikacji nagrody albo dedykowane do wydłużenia jej cyklu życia itp.
Np. kolejna metoda mogłaby wyglądać tak:
@Transactional
public Reward modifyReward(Long rewardId, RewardDto rewardDto) {
Reward reward = rewardRepository.findById(rewardId)
.orElseThrow(() -> new ResourceNotFoundException("Reward", "id", rewardId));
if (rewardDto.getStartDate().isBefore(LocalDate.now())) {
throw new IllegalArgumentException("Start date must be from the future");
}
if (rewardDto.getEndDate().isBefore(rewardDto.getStartDate())) {
throw new IllegalArgumentException("Start date must be before end date");
}
//change the values
return reward;
}
Pewnie większości programistów od razu rzuca się w oczy, że mamy walidację daty nagrody w dwóch miejscach w obu metodach. Trzeba coś z tym zrobić! Najczęstsze pomysły to utworzenie klasy pomocniczej lub ekstrakcja walidacji do metody prywatnej. Zacznijmy od Utila. Musimy jednak pamiętać, że nazwa klasy powinna odzwierciedlać jej przeznaczenie, więc samo Util
skończy jako worek na wszystko. Więc może RewardUtil
, RewardDateRangeUtil
lub RewardDateRangeValidator
? Chyba że drugie podejście, czyli pozostanie w tej samej klasie i ekstrakcja logiki walidacji do prywatnej metody.
Co zrobić!? Szybka rozkmina. Prywatna metoda ogranicza dostęp do walidacji tylko do danej klasy. Jeśli walidacja będzie potrzebna również gdzie indziej, potrzebny będzie dedykowany Util/Validator. Musimy jednak pamiętać, aby używać go zawsze przed dokonaniem zmiany. To nie jest tak oczywiste, bo logika walidacji znajduje się w innej klasie.
Cokolwiek wybierzemy, musielibyśmy dodawać dodatkowy kod przy niemal każdym kontakcie z Reward
, żeby nie pozwolić wprowadzić go w błędny stan. Zawsze jednak ktoś może zapomnieć. Czy możemy zrobić to jakoś inaczej? Tu wchodzi cały na biało Value Object!
Wracamy do postaw OOP, jeśli coś wzajemnie na siebie wpływa i razem się zmienia, to wrzucamy to do jednego worka, w ten sposób dążymy do wysokiej kohezji. Możemy zrobić coś takiego:
@Embeddable
class RewardDateRange {
private LocalDate startDate;
private LocalDate endDate;
RewardDateRange(LocalDate startDate, LocalDate endDate) {
if (startDate.isBefore(LocalDate.now())) {
throw new IllegalArgumentException("Start date must be from the future");
}
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("Start date must be before end date");
}
this.startDate = startDate;
this.endDate = endDate;
}
private RewardDateRange() {
}
//equals && hashCode && toString
}
@Entity
class Reward extends BaseEntity {
private String name;
private Integer cost;
@Embedded
private RewardDateRange rewardDateRange;
Reward(String name, Integer cost, LocalDate startDate, LocalDate endDate) {
this.name = name;
this.cost = cost;
this.rewardDateRange = new RewardDateRange(startDate, endDate);
}
//etc...
@Service
class RewardService {
//injections
public Reward createReward(RewardDto rewardDto) {
Reward reward = new Reward(rewardDto.getName(), rewardDto.getCost(),
rewardDto.getStartDate(), rewardDto.getEndDate());
return rewardRepository.save(reward);
}
//other methods
Teraz kiedy tworzymy lub modyfikujemy obiekt Reward
, nie musimy martwić się o dodatkowe sprawdzenia walidacji dat. Zawsze jest to zagwarantowane, nikt nie zapomni ani się nie pomyli. Co więcej, część testów integracyjnych może teraz stać się jednostkowymi, co przyspiesza testowanie naszego systemu. Z czasem pewnie pojawią się jakieś nowe zachowania dotyczące tych dat. To też dodatkowy plus posiadania klasy RewardDateRange
. Wszystko mamy zamknięte w „czarnym pudełku”, co zapewni dużą spójność i ułatwi utrzymanie takiego kodu. Przykładowo, dodajemy nowe metody do sprawdzenia, czy Reward
jest aktualnie dostępny, oraz do wydłużenia ważności Reward
:
@Embeddable
class RewardDateRange {
private LocalDate startDate;
private LocalDate endDate;
RewardDateRange(LocalDate startDate, LocalDate endDate) {
if (startDate.isBefore(LocalDate.now())) {
throw new IllegalArgumentException("Start date must be from the future");
}
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("End date must be after start date");
}
this.startDate = startDate;
this.endDate = endDate;
}
boolean isAvailableAt(LocalDate date) {
return date.isAfter(this.startDate) && date.isBefore(this.endDate);
}
RewardDateRange extendDate(LocalDate newEndDate) {
if (newEndDate.isBefore(this.endDate)) {
throw new IllegalArgumentException("New end date must be after old end date");
}
return new RewardDateRange(startDate, newEndDate);
private RewardDateRange() {
}
//equals && hashCode && toString
}
Testowanie i utrzymanie tego to czysta przyjemność 🙂 Co więcej, jeśli w naszym systemie jest więcej miejsc z tą samą logiką, nic nie stoi na przeszkodzie, aby stworzyć obiekt DateRange
zamiast RewardDateRange
i wykorzystać go w wielu miejscach. Mam nadzieję, że „czujesz to”, że jest to naprawdę fajne, a to dopiero początek. Przejdźmy teraz do kolejnego problemu, który rozwiązuje Value Object.
2. Obsesja typów prymitywnych
Przyjrzyjmy się jeszcze raz encji Reward
, uwzględniając ostatnie zmiany.
@Entity
class Reward extends BaseEntity {
private String name;
private Integer cost;
@Embedded
private RewardDateRange rewardDateRange;
Reward(String name, Integer cost, LocalDate startDate, LocalDate endDate) {
this.name = name;
this.cost = cost;
this.rewardDateRange = new RewardDateRange(startDate, endDate);
}
//etc...
Czy któreś z pozostałych pól powinno być Value Objectem? Może name
, może cost
, a może name
i cost
razem? Odpowiedź, jak zawsze, brzmi „to zależy”. Jeżeli name
i cost
wzajemnie na siebie wpływają, na przykład, jeśli nazwa zaczyna się na „PRO_”, to koszt jest podwójny, wówczas można to zamodelować jako jeden VO. Jeżeli nie wpływają na siebie, to nie ma sensu wrzucać ich do jednego VO. Zakładamy, że u nas w systemie na siebie nie wpływają. Lecimy dalej, czy samo name
powinno być VO? Znowu to zależy. Jeżeli mamy jakąś logikę biznesową związaną z nazwą, lub chcemy zamknąć w jednym pudełku jakąś walidację, bo na przykład nazwa naszego Reward
musi spełniać jakiś pattern, wtedy stworzenie z tego VO będzie jak najbardziej ok. Jeżeli to zwykła nazwa, która nie ma większej walidacji i nie wpływa na logikę biznesową, to nie widzę sensu robić z tego VO.
Przejdźmy więc dalej do cost
. Tu jest ciekawiej, bo jeśli zapytam Cię, w czym jest wyrażanycost
, to nie wiadomo. Może to jakieś pieniądze, może punkty lojalnościowe, może eurogąbki, cholera wie. Jeżeli mamy obiekt, który jest związany z naszym biznesem i reprezentuje jakąś wartość, to dobry kandydat na VO. Taki obiekt prawdopodobnie będzie używany w wielu miejscach, bo skoro Reward
ma cost
, to pewnie Customer
będzie reprezentował w tym samym przeliczniku kwotę, którą posiada.
Z reguły kod częściej czytamy niż piszemy. Jak czytamy kodzik, to chcemy wiedzieć, z czym mamy do czynienia, nawet jeśli jesteśmy nowi w projekcie. Samo Integer cost;
niewiele nam mówi. Lepiej, gdyby to nie był Integer
, ale konkretny typ. Dodatkowo modelowanie systemu w ten sposób, że mamy konkretne typy, zmniejsza szanse na pomyłki. Po pierwsze, wiemy z czym mamy do czynienia i łatwiej nam korzystać z takich obiektów. Po drugie, jeżeli funkcja przyjmuje kilka argumentów tego samego typu, łatwiej jest się pomylić, niż w przypadku funkcji przyjmującej argumenty różnych typów.
W związku z tym dochodzimy do wniosku, że warto od razu wiedzieć z kodu, czym jest cost
. Więc zmieńmy to! Żeby było śmieszniej, załóżmy, że koszt nagrody jest wyrażany w eurogąbkach. Zróbmy też tak, że w klasie EuroSponge
, chcemy mieć nazwę pola reprezentującego wartość jako value
, a nie cost
.
@Entity
class Reward extends BaseEntity {
private String name;
@Embedded
@AttributeOverride(name = "value", column = @Column(name = "cost"))
private EuroSponge cost;
@Embedded
private RewardDateRange rewardDateRange;
Reward(String name, Integer cost, LocalDate startDate, LocalDate endDate) {
this.name = name;
this.cost = new EuroSponge(cost);
this.rewardDateRange = new RewardDateRange(startDate, endDate);
}
//etc...
Jest czytelniej! Ale czy nasz VO EuroSponge
rozwiązuje jeszcze jakieś problemy?
3. Brak kontroli nad metodami
Jeśli modelujemy pieniądze w naszych klasach domenowych, nawet jak jesteśmy już bardziej „sinior” i używamy BigDecimal
, to nadal mamy kilka problemów. Po pierwsze, logika wykonywania operacji może być rozproszona. Na przykład, jeśli zaokrąglamy w dół, musimy zawsze pamiętać o dodaniu linijki kodu odpowiedzialnej za zaokrąglenie lub wywołaniu odpowiedniego Utila. Musimy to robić praktycznie przy każdym kontakcie z pieniędzmi w naszym systemie. Drugi problem to fakt, że dając dostęp do wszystkich metod z BigDecimal
na naszym polu reprezentującym pieniądze, dajemy zbyt duże pole do manewru. Nie potrzebujemy wszystkich operacji matematycznych dostępnych w BigDecimal
dla naszej reprezentacji pieniędzy. Nasz obiekt domenowy powinien mieć metody, które odpowiadają jego rzeczywistemu zachowaniu. Zostawienie „otwartych drzwi” może skutkować tym, że w końcu ktoś użyje jakiejś metody, której nie powinien. Dlaczego? Bo pojawiła się po kropeczce.
Jeśli zamkniemy nasz pieniądz w Value Object, nie będziemy mieli problemu, z tym że ktoś zapomni wywołać zaokrąglenie. Dodatkowo nie będziemy musieli martwić się, że ktoś skorzysta z metody, której nie powinien używać. Daje kropeczkę na obiekcie reprezentującym pieniądz i ma tylko te metody, z których można skorzystać. Każda z nich zapewnia już zaokrąglenie i spełnia logikę biznesową. Co więcej, nazwa takiej metody również od razu sugeruje, co ta metoda robi w kontekście biznesowym.
Dodatkowo nie musimy się martwić, że ktoś, modyfikując obiekt, wprowadzi go w niepoprawny stan. VO nie da się zmodyfikować — zamiast tego zostanie stworzony nowy. Walidacje przeprowadzamy w konstruktorze, więc jeśli mamy taki cost
i nie może on być ujemny, nie musimy się martwić o dodawanie walidacji podczas operacji odejmowania. Podczas odejmowania zostanie wywołany konstruktor, który pozwoli utworzyć nowy obiekt VO, tylko w poprawnym stanie. Dodatkowo testowanie VO, jak już wspominałem, jest łatwe do utrzymania i szybkie dzięki testom jednostkowym.
Zobaczmy, jak może wyglądać VO dla naszych eurogąbeczek.
@Embeddable
public class EuroSponge {
public static final EuroSponge ZERO = new EuroSponge(0);
private Integer value;
public EuroSponge(Integer value) {
if (value < 0) {
throw new IllegalArgumentException("Euro sponge cannot be negative");
}
this.value = value;
}
public EuroSponge add(EuroSponge euroSpongeToAdd) {
return new EuroSponge(this.value + euroSpongeToAdd.value());
}
public EuroSponge subtract(EuroSponge euroSpongeToSubtract) {
return new EuroSponge(this.value - euroSpongeToSubtract.value());
}
public Integer value() {
return this.value;
}
private EuroSponge() {
}
//other methods
//equals && hashCode && toString
}
Persystencja Value Objects
Często pojawia się pytanie, jak zapisać Value Object w bazie danych. Żeby uniknąć takich pytań, celowo w moich przykładach prezentuję VO, które są bezpośrednio w encji. To jest akurat scenariusz, który narzuca jakieś dodatkowe adnotacje i konstruktor na VO, żeby usatysfakcjonować JPA. Czy te dodatkowe zależności są nam potrzebne? To zależy od naszego projektu. Możemy mieć bazę relacyjną, a nie mieć JPA, możemy mieć JPA, ale zapisywać VO w kolumnach JSONowych, możemy mieć bazę nierelacyjną i tak dalej — wtedy te dodatki nie będą nam potrzebne. Co więcej, jeśli nie zapisujemy VO jako zwykłe kolumny z użyciem JPA, możemy skorzystać z klas typu record
, dostępnych od Java 14, lub z adnotacji @Value
z Lombok, co jeszcze bardziej ułatwia ich tworzenie.
@Value
public class EuroSponge {
public static final EuroSponge ZERO = new EuroSponge(0);
Integer value;
public EuroSponge(Integer value) {
if (value < 0) {
throw new IllegalArgumentException("Euro sponge cannot be negative");
}
this.value = value;
}
public EuroSponge add(EuroSponge euroSpongeToAdd) {
return new EuroSponge(this.value + euroSpongeToAdd.value);
}
public EuroSponge subtract(EuroSponge euroSpongeToSubtract) {
return new EuroSponge(this.value - euroSpongeToSubtract.value);
}
// @Value automatically generates equals, hashCode,
// and toString methods, and all fields are automatically final
}
public record EuroSponge(Integer value) {
public static final EuroSponge ZERO = new EuroSponge(0);
public EuroSponge {
if (value < 0) {
throw new IllegalArgumentException("Euro sponge cannot be negative");
}
}
public EuroSponge add(EuroSponge euroSpongeToAdd) {
return new EuroSponge(this.value + euroSpongeToAdd.value());
}
public EuroSponge subtract(EuroSponge euroSpongeToSubtract) {
return new EuroSponge(this.value - euroSpongeToSubtract.value());
}
// record automatically generates equals, hashCode,
// and toString methods, and all fields are automatically final
}
Teraz chciałbym wyjaśnić pewne kwestie, ponieważ pokazywałem wcześniej Value Objects, które na koniec dnia zostają zapisane na bazke. Najczęściej korzystamy z Value Objects w warstwie domenowej, chociaż tworzyć je można również w innych warstwach. Ważne jest, aby pamiętać, że warstwa domenowa to nie to samo co warstwa persystencji. Dlatego nie zawsze będziemy chcieli zapisywać VO w bazie danych. Value Objects nie mają swojego cyklu życia. Jeżeli zdecydujemy się na persystencję VO, musimy umieścić go w obiekcie, który będzie zapisywany, na przykład w encji.
Value Object vs Encja
Często podczas tworzenia nowego obiektu zadajemy sobie pytanie, czy powinien to być Value Object, czy Encja.
Poniższa tabelka powinna pomóc udzielić odpowiedzi na takie pytania.
Value Objects | Encje | |
---|---|---|
Tożsamość | Nie mają tożsamości, są zdefiniowane przez ich wartość. | Mają unikalną tożsamość, która przetrwa mimo zmiany atrybutów. |
Unikalność | Dwa VO są identyczne, jeśli wszystkie ich atrybuty są identyczne. | Każda encja ma swój unikalny identyfikator. |
Cykl życia | Nie mają swojego własnego cyklu życia, istnieją jako część innego obiektu np. encji. | Mają swój własny cykl życia, są bezpośrednio zarządzane przez mechanizm persystencji. |
Zmienność (Mutability) | Są niemutowalne – ich stan jest ustalany podczas tworzenia i nie można go zmienić. Jeżeli chcemy zmienić jakiś atrybut, musimy stworzyć nowy VO. | Są mutowalne i ich stan może zmieniać. Czyli po zmianie stanu nadal mamy ten sam obiekt. |
Uwaga! To, co w jednej domenie jest VO, w innej może być Encją! Na przykład, na początku wpisu mówiliśmy, że my, jako społeczeństwo, identyfikujemy banknot przez jego wartość. Dlatego dla nas jest to VO. W kontekście mennicy, która drukuje banknoty, każdy banknot będzie miał unikalny numer seryjny, który go identyfikuje, dlatego tam będzie już Encją. Jak to się mówi: „Context is a king!” 🙂
Identyfikator jako Value Object
Identyfikatory są często reprezentowane jako proste typy, takie jak liczby całkowite lub ciągi znaków. Jednakże, używanie VO jako Id pozwala na wprowadzenie dodatkowej warstwy abstrakcji i zasad. Na przykład, jeśli mamy Identyfikator Użytkownika w postaci VO, możemy zagwarantować, że zawsze jest on poprawny, dodatkowo może posiadać dedykowane zachowania. Wprawdzie zwiększa to złożoność projektu, ale takie silniejsze typowanie również pozwala uniknąć błędów, jak np. przekazanie złego typu jako argumentu metody. Czasami identyfikatory składają się z kilku pól i muszą spełniać określone zasady tworzenia. W takich przypadkach użycie VO zwiększa bezpieczeństwo, wkomponowując walidację bezpośrednio w obiekt.
Korzystanie z Value Object jako identyfikatorów to podejście oferujące wiele korzyści, zwłaszcza w bardziej złożonych domenach. Czy Id jako Value Object to zawsze dobry pomysł? Jak zawsze, wszystko zależy od konkretnego przypadku. Dodatkowo warto pamiętać, że kluczem jest umiar. Gdy poczujemy moc VO, możemy popaść w obsesję i stosować je wszędzie, co z kolei może prowadzić do nadmiernej komplikacji. Jak ze wszystkim, łatwo przesadzić i osiągnąć odwrotny efekt. 🙂
Podsumowanie
Value Object to prosty wzorzec, ale dla mnie poznanie go było game changerem. Modelując domenę, możemy to co razem się zmienia zamknąć w jeszcze mniejszych „pudełkach” i przetestować jednostkowo. Co więcej, nawet jeśli nasz kod nie jest piękny, warto zamknąć pewne elementy za stabilnym interfejsem i napisać testy. Pozwala to później zrobić refaktoryzację ze spokojną głową. Zawsze lepiej mieć kilka małych 💩, niż jedną wielką. Dlatego wzorzec VO często jest wybierany jako pierwszy krok w refaktoryzacji. Pozwala lepiej zrozumieć domenę i wyodrębnić do zamkniętych „pudełek” te elementy, które zmieniają się razem.
Jeśli nie korzystasz z VO w swoim projekcie, mam nadzieję, że ten wpis przynajmniej trochę Cię przekonał do wypróbowania tego wzorca. Poniżej znajdziesz link do mojego repozytorium na GitHubie, gdzie na pewno znajdziesz VO użyte w kodziku. Jeśli chcesz sprawdzić swoją wiedzę z zakresu Senior Java Developer, gorąco zachęcam Cię do skorzystania z mojego quizu. Link do niego także poniżej.
Jeśli ten wpis Ci się podobał i chcesz być na bieżąco, zapisz się do newslettera. Dla mnie to zawsze dodatkowa motywacja do dalszego prowadzenia bloga. Siemanko!
Zostaw swój e-mail, a raz na jakiś czas wyśle Ci info o nowych postach na blogu. Tylko wartościowe e-mail!