Archetyp Party w Spring i Neo4j
Mimo że archetypy biznesowe są z nami od dłuższego czasu, wciąż nie cieszą się dużą popularnością. Trudno powiedzieć, dlaczego tak się dzieje, mogę się jedynie domyślać.
Dopiero od niedawna w polskim IT zaczęło się coś ruszać w tej kwestii. Jakiś czas temu powstała strona poświęcona archetypom — softwarearchetypes.com — na której Kuba Pilimon wraz z Bartkiem Słotą zamieścili kilka przykładów ich implementacji w Javie. Teraz poszło to o krok dalej: mamy komercyjny projekt od Devstyle o nazwie Archetypy Oprogramowania, który niedługo ujrzy światło dzienne.
Koncepcja archetypów jest naprawdę ciekawa i warta uwagi. Sam staram się ją śledzić i obserwować od jakiegoś czasu. Dziś postanowiłem podzielić się z Tobą kodem, w którym wykorzystałem fragment rozwiązania udostępniony przez wcześniej wspomnianych autorów, a dokładnie dotyczący archetypu “Party”. Chciałem spróbować połączyć zaproponowany model domenowy z infrastrukturą, aby zobaczyć, jak to może działać w praktyce. Nie znajdziesz więc tutaj sposobów na to, jak odkryć archetyp w swoim biznesie, ani omówienia, jakie są archetypy. Przybliżę Ci tylko aspekty czysto techniczne, które być może Ci się przydadzą albo będą po prostu ciekawostką. Zapraszam!
Czym jest archetyp Party?
Nie będę się tutaj rozpisywał o samym archetypie, ponieważ naprawdę pogłębiony opis znajduje się na stronie softwarearchetypes.com. Gdybym miał go opisać w kilku słowach, ten archetyp skupia się na stronie, która może być reprezentowana przez osobę fizyczną, firmę lub inną organizację. Przypisywane są do niej cechy, które ją definiują – może to być imię i nazwisko danej osoby albo identyfikator firmy, taki jak NIP.
Najistotniejsze jest jednak to, że te strony mogą wchodzić ze sobą w relacje. Sama relacja jest również reprezentowana przez byt w modelu, który informuje nas o tym, w jakiej relacji jest dana strona z inną, jakie role w niej przyjmuje, jaką ta relacja ma nazwę i co w ogóle umożliwia. Dodatkowo mamy możliwość definiowania reguł, które pozwalają nam tworzyć poprawne biznesowo relacje.
Sam archetyp “Party” skupia się więc bardziej na danych i cechach, które opisują dany byt, czyli rzeczownikach. Dzięki temu, że mamy taki szkielet, który precyzyjnie opisuje strony i relacje, możemy dodać zachowania informujące o tym, co jest możliwe, a co nie. Możemy również określić, co zyskujemy dzięki danej relacji jako strona, a czego niestety nie możemy uzyskać z powodu jej braku.
Dzięki istnieniu takich archetypów nie muszę już zastanawiać się nad tym, jak wymyślać koło na nowo. Mogę to po prostu zauważyć w danym biznesie i od razu wiedzieć, jak dana rzecz funkcjonuje. Poznanie tych archetypów ułatwia mi porozumienie z drugą stroną, ponieważ jest to uniwersalne podejście obecne w wielu branżach. A przynajmniej powinno być…
Przejdźmy jednak do implementacji. Na początku spójrzmy, co zostało zamodelowane przez Kubę i Bartka. Postanowili oni oderwać się od konkretnej implementacji – w sensie od frameworka – i stworzyć w Javie szkielet, który będzie można podłączać do różnego rodzaju “adapterów”.
Model domenowy archetypu “Party”
Sama klasa “Party” nie jest niczym niezwykłym. Znajdują się w niej podstawowe atrybuty, takie jak identyfikatory czy role. Dodatkowo jest też lista zdarzeń, która pozwala nam śledzić, co dzieje się z danym bytem — na przykład, czy dana rola została dodana, czy usunięta. Z powodu optymistycznego blokowania mamy również wersjonowanie, które pozwala nam kontrolować, czy nikt inny nie zmienił danego bytu w tak zwanym “międzyczasie”.
public sealed abstract class Party permits Organization, Person {
private final PartyId partyId;
private final Set<Role> roles;
private final Set<RegisteredIdentifier> registeredIdentifiers;
private final List<PartyRelatedEvent> events = new LinkedList<>();
private final Version version;
Party(PartyId partyId, Set<Role> roles, Set<RegisteredIdentifier> registeredIdentifiers, Version version) {
checkArgument(partyId != null, "Party Id cannot be null");
checkArgument(roles != null, "Roles cannot be null");
checkArgument(registeredIdentifiers != null, "Registered identifiers cannot be null");
checkArgument(version != null, "Version cannot be null");
this.partyId = partyId;
this.roles = new HashSet<>(roles);
this.registeredIdentifiers = new HashSet<>(registeredIdentifiers);
this.version = version;
}
public Result<RoleAdditionFailed, Party> add(Role role) {
checkNotNull(role, "Role cannot be null");
if (!roles.contains(role)) {
roles.add(role);
events.add(new RoleAdded(partyId.asString(), role.asString()));
} else {
//for idempotency
events.add(RoleAdditionSkipped.dueToDuplicationFor(partyId.asString(), role.asString()));
}
return Result.success(this);
}
// other similar methods
}
W tym miejscu nie ma sprawdzenia, czy dana strona może przyjąć określoną rolę. Jednak nic nie stoi na przeszkodzie, żeby na wyższej warstwie coś takiego dodać. To samo mogłoby się tyczyć identyfikatorów, aby chronić się przed sytuacją, w której firmie zostanie przypisany numer PESEL.
Z racji dziedziczenia dowiadujemy się, że nie skupiamy się tutaj stricte na zachowaniach, lecz bardziej na danych. Gdybyśmy się przyjrzeli bliżej klasom Person i Organization, to dostrzeżemy, że są one dość proste i zawierają jedynie atrybuty charakterystyczne dla danej strony, takie jak OrganizationName.
public sealed abstract class Organization extends Party permits Company, OrganizationUnit {
private OrganizationName organizationName;
// other staff
}
Ciekawiej robi się przy relacjach. Jest tam również prosta struktura danych reprezentująca relację między dwiema stronami. Warto jednak przyjrzeć się klasie PartyRelationshipFactory, a dokładniej metodzie defineFor. To ona nam określa, czy dana relacja ma prawo zaistnieć, czy też nie. A wszystko to dzięki strategiom, które możemy sobie definiować i dodawać do fabryki.
class PartyRelationshipFactory {
private static final PartyRelationshipDefiningPolicy DEFAULT_PARTY_RELATIONSHIP_DEFINING_POLICY = new AlwaysAllowPartyRelationshipDefiningPolicy();
private final PartyRelationshipDefiningPolicy policy;
private final Supplier<PartyRelationshipId> partyRelationshipIdSupplier;
PartyRelationshipFactory(PartyRelationshipDefiningPolicy policy, Supplier<PartyRelationshipId> partyRelationshipIdSupplier) {
this.policy = policy != null ? policy : DEFAULT_PARTY_RELATIONSHIP_DEFINING_POLICY;
this.partyRelationshipIdSupplier = partyRelationshipIdSupplier != null ? partyRelationshipIdSupplier : PartyRelationshipId::random;
}
PartyRelationshipFactory(Supplier<PartyRelationshipId> partyRelationshipIdSupplier) {
this(null, partyRelationshipIdSupplier);
}
Result<PartyRelationshipDefinitionFailed, PartyRelationship> defineFor(PartyRole from, PartyRole to, RelationshipName name) {
if (policy.canDefineFor(from, to, name)) {
return Result.success(PartyRelationship.from(partyRelationshipIdSupplier.get(), from, to, name));
} else {
return Result.failure(dueToPoliciesNotMet());
}
}
}
Czyli podsumowując, mamy tutaj do czynienia z solidną podstawą, którą możemy wykorzystać w naszym projekcie. Jeśli odkryjemy w biznesie, że potrzebujemy archetypu “Party”, to spokojnie można się na tej implementacji oprzeć, jeśli chodzi o model domenowy. Ciekawiej zaczyna się jednak robić, gdy chcemy to podłączyć do infrastruktury. Bo faktycznie, szukanie powiązań naszej domeny z archetypami może być wyzwaniem. Pozostaje jednak też ta druga strona medalu w postaci technikaliów, którym się teraz przyjrzymy.
Podłączenie archetypu “Party” z wykorzystaniem Spring i Neo4j
Trzeba teraz połączyć dwa światy. Na pewno trudno jest stworzyć odpowiedni model domenowy, który będzie pasował do danego biznesu. Potem napotykamy jednak trudność z podłączeniem tego do infrastruktury. Zobaczmy więc, jak to może wyglądać w praktyce. Wybrałem do tego celu Spring Boota oraz bazę grafową Neo4j, z którą nigdy wcześniej nie miałem do czynienia. Chciałem się z nią zapoznać przy okazji tego eksperymentu.
Zacznijmy od tego, że skopiowałem sobie model domenowy z repozytorium Software-Archetypes do swojego projektu. Umieściłem wszystkie niezbędne klasy w module Gradle o nazwie party-archetype-model, aby pilnować się na poziomie kompilacji, żeby model nie zależał od klas związanych ze Springiem.
.
├── party-archetype-model
└── party-archetype-spring
Wszystkie podłączenia kabelków, jeśli chodzi o infrastrukturę, umieściłem w pakiecie party-archetype-spring. Czyli to tutaj znajdują się zależności do Spring Boota oraz Neo4j. No i oczywiście do modelu.
Struktura pakietów wygląda następująco:
pl.cezarysanecki.partyarchetypeapp
├── .api
├── .application
├── .config
├── .infrastructure
└── .utils
W pakiecie api znajdują się kontrolery REST, które wystawiają endpointy do zarządzania stronami i relacjami. W application umieściłem “use cases”, które korzystają z fasad z modelu. W config są klasy konfiguracyjne, które pozwalają rejestrować fasady w kontekście Springa. W infrastructure znajdują się implementacje repozytoriów Neo4j wraz z klasami pomocniczymi. Na sam koniec w utils umieściłem kilka klas pomocniczych, a dokładniej fabryk, które dynamicznie pozwalają mi tworzyć instancje klas z modelu domenowego. Trochę to niefortunne, bo niektóre klasy mają takie same nazwy jak te z modelu. Jednak w niczym to nie przeszkadza, przynajmniej w tym “eksperymencie”.
Warstwa api
Idąc od góry do dołu, do komunikacji ze światem zewnętrznym niezbędne nam są kontrolery REST. Można je zaimplementować na dwa sposoby: każdy use case może mieć osobny kontroler albo jeden kontroler może obsługiwać jeden koncept z modelu. To tak naprawdę szczegół implementacyjny. Ja na swoje potrzeby wybrałem pierwsze podejście.
@RestController
@RequestMapping("/api/relationships")
public class AddRelationshipController {
private final AddRelationshipUseCase useCase;
public AddRelationshipController(AddRelationshipUseCase useCase) {
this.useCase = useCase;
}
@PostMapping
public ResponseEntity<?> addRelationship(@RequestBody AddRelationshipCommand command) {
var result = useCase.execute(
PartyId.of(UUID.fromString(command.fromId())),
new Role(command.fromRole()),
PartyId.of(UUID.fromString(command.toId())),
new Role(command.toRole()),
new RelationshipName(command.name())
);
return result.fold(
failure -> ResponseEntity.badRequest().body(failure),
ResponseEntity::ok
);
}
public record AddRelationshipCommand(String fromId, String fromRole, String toId, String toRole, String name) {}
}
Pewnie można zrobić to lepiej, bo nie ma tutaj wyrafinowanej obsługi zwrotek. Jednak na potrzeby tego eksperymentu wystarczyło mi to, co jest.
Warstwa application
Również nic specjalnego. Mamy tutaj np. klasę AddRelationshipUseCase, która korzysta z fasad z modelu domenowego, aby dodać relację między dwoma stronami. Można by to zrobić oczywiście inaczej, nie tworząc tylu klas. Jest to jednak kwestia preferencji. W tym przypadku jest to tylko przelotka, więc tak naprawdę można by nie tworzyć tej klasy i korzystać z fasady w kontrolerze. Daje nam to jednak miejsce, w którym moglibyśmy zbierać metryki biznesowe albo logować coś, co jest istotne. Jeśli nie ma takiej potrzeby, to pewnie lepiej byłoby nie tworzyć tej klasy.
@Service
public class AddRelationshipUseCase {
private final PartyRelationshipsFacade partyRelationshipsFacade;
public AddRelationshipUseCase(PartyRelationshipsFacade partyRelationshipsFacade) {
this.partyRelationshipsFacade = partyRelationshipsFacade;
}
public Result<PartyRelatedFailureEvent, PartyRelationship> execute(
PartyId fromId,
Role fromRole,
PartyId toId,
Role toRole,
RelationshipName name
) {
return partyRelationshipsFacade.assign(fromId, fromRole, toId, toRole, name);
}
}
Warstwa config
Ponownie, nic niezwykłego. To miejsce, które łączy dwa światy – Springa i model domenowy. Rejestrujemy serwisy aplikacyjne z domeny w kontekście Springa.
@Configuration
public class PartyRelationshipsConfig {
private final PartyRelationshipRepository partyRelationshipRepository;
private final EventPublisher eventPublisher;
public PartyRelationshipsConfig(PartyRelationshipRepository partyRelationshipRepository, EventPublisher eventPublisher) {
this.partyRelationshipRepository = partyRelationshipRepository;
this.eventPublisher = eventPublisher;
}
@Bean
PartyRelationshipsFacade partyRelationshipsFacade(PartiesFacade partiesFacade) {
return new PartyRelationshipsFacade(
partiesFacade,
new PartyRelationshipFactory(PartyRelationshipId::random),
partyRelationshipRepository,
eventPublisher
);
}
}
Warstwa infrastructure
No i crème de la crème! Tutaj dzieje się magia, jeśli chodzi o persystencję. Pierwotnie myślałem, aby skorzystać z gotowych klocków Spring Data Neo4j, ale ostatecznie postanowiłem zrobić to samodzielnie. Głównie z powodu konieczności mapowania klas domenowych na byty bazy grafowej, co wydawało mi się niemożliwe do zrobienia w “klasyczny” sposób. Przyjrzyjmy się jednej z klas repozytoriów.
@Repository
class Neo4jPartyRelationshipRepository implements PartyRelationshipRepository {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.registerModule(PartyRelationshipStd.PARTY_RELATIONSHOP_MODULE);
private final Neo4jClient neo4jClient;
Neo4jPartyRelationshipRepository(Neo4jClient neo4jClient) {
this.neo4jClient = neo4jClient;
}
@Override
public void save(PartyRelationship partyRelationship) {
neo4jClient.query(
"MATCH (from:Party {id: $fromId}), (to:Party {id: $toId}) " +
"MERGE (from)-[r:" + partyRelationship.name().asString() + " {id: $relId}]->(to) " +
"SET r += $props"
)
.bindAll(Map.of(
"fromId", partyRelationship.from().partyId().asString(),
"toId", partyRelationship.to().partyId().asString(),
"relId", partyRelationship.id().asString(),
"props", OBJECT_MAPPER.convertValue(partyRelationship, Map.class)
))
.run();
}
// other methods
}
Widać wykorzystanie klasy Neo4jClient, która pozwala mi wykonywać zapytania Cypher. W metodzie save dopasowuję się do dwóch stron na podstawie ich identyfikatorów, a następnie tworzę lub aktualizuję relację między nimi. Warto zauważyć, że nazwa relacji jest dynamiczna i pochodzi z modelu domenowego. Jednak to, co bardziej powinno przykuć uwagę, to PARTY_RELATIONSHIP_MODULE. Jest to moduł Jacksona, który pozwala mi serializować i deserializować obiekty PartyRelationship. Dzięki temu mogę łatwo zamienić obiekt na mapę właściwości, którą mogę przypisać do relacji w Neo4j. To samo działa w drugą stronę, gdy pobieram relacje z bazy.
class PartyRelationshipStd {
static final Module PARTY_RELATIONSHOP_MODULE = new SimpleModule()
.addSerializer(PartyRelationship.class, new PartyRelationshipStd.Serializer())
.addDeserializer(PartyRelationship.class, new PartyRelationshipStd.Deserializer());
private static final String ID = "id";
private static final String FROM = "from";
private static final String FROM_ROLE = "fromRole";
private static final String TO = "to";
private static final String TO_ROLE = "toRole";
private static final String NAME = "name";
static class Serializer extends StdSerializer<PartyRelationship> {
public Serializer() {
super(PartyRelationship.class);
}
@Override
public void serialize(PartyRelationship value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeStartObject();
gen.writeStringField(ID, value.id().asString());
gen.writeStringField(FROM, value.from().partyId().asString());
gen.writeStringField(FROM_ROLE, value.from().role().name());
gen.writeStringField(TO, value.to().partyId().asString());
gen.writeStringField(TO_ROLE, value.to().role().name());
gen.writeStringField(NAME, value.name().asString());
gen.writeEndObject();
}
}
static class Deserializer extends StdDeserializer<PartyRelationship> {
public Deserializer() {
super(PartyRelationship.class);
}
@Override
public PartyRelationship deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode node = p.getCodec().readTree(p);
var id = PartyRelationshipId.of(node.get(ID).asText());
var fromPartyId = PartyId.of(node.get(FROM).asText());
var fromRole = new Role(node.get(FROM_ROLE).asText());
var toPartyId = PartyId.of(node.get(TO).asText());
var toRole = new Role(node.get(TO_ROLE).asText());
var name = new RelationshipName(node.get(NAME).asText());
var from = new PartyRole(fromPartyId, fromRole);
var to = new PartyRole(toPartyId, toRole);
return new PartyRelationship(id, from, to, name);
}
}
}
Właśnie to sprawiło mi największą trudność, ale wydaje mi się, że jak ktoś już to kiedyś robił, to dzięki “EjAj” nie będzie miał z tym problemu. Ciekawiej ma się jednak klasa służąca deserializacji klasy Party.
class PartyStd {
static final Module PARTY_MODULE = new SimpleModule()
.addSerializer(Party.class, new Serializer())
.addDeserializer(Party.class, new Deserializer());
private static final PartyFactory PARTY_FACTORY = new PartyFactory();
private static final RegisteredIdentifiersFactory REGISTERED_IDENTIFIERS_FACTORY = new RegisteredIdentifiersFactory();
private static final String ID = "id";
private static final String REGISTERED_IDENTIFIERS_PREFIX = "registeredIdentifiers.";
private static final String ROLES = "roles";
private static final String FIRST_NAME = "firstName";
private static final String LAST_NAME = "lastName";
private static final String ORGANIZATION_NAME = "organizationName";
private static final String TYPE = "type";
private static final String VERSION = "version";
// Serializer ommitted
static class Deserializer extends StdDeserializer<Party> {
public Deserializer() {
super(Party.class);
}
@Override
public Party deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode jsonNode = p.getCodec().readTree(p);
String type = jsonNode.get(TYPE).asText();
PartyId partyId = PartyId.of(jsonNode.get(ID).asText());
Version version = Version.of(jsonNode.get(VERSION).asLong());
Set<Role> roles = StreamSupport.stream(jsonNode.get(ROLES).spliterator(), false)
.map(roleName -> new Role(roleName.asText()))
.collect(Collectors.toSet());
Set<RegisteredIdentifier> registeredIdentifiers = jsonNode.properties().stream()
.filter(nodeEntry -> nodeEntry.getKey().startsWith(REGISTERED_IDENTIFIERS_PREFIX))
.map(nodeEntry -> REGISTERED_IDENTIFIERS_FACTORY.create(
nodeEntry.getKey().substring(REGISTERED_IDENTIFIERS_PREFIX.length()),
nodeEntry.getValue().asText()))
.collect(Collectors.toSet());
Class<? extends Party> partySubclass = PARTY_FACTORY.findSubclassBy(type);
if (Person.class.isAssignableFrom(partySubclass)) {
String firstName = jsonNode.get(FIRST_NAME).asText();
String lastName = jsonNode.get(LAST_NAME).asText();
PersonalData personalData = new PersonalData(firstName, lastName);
return new Person(partyId, personalData, roles, registeredIdentifiers, version);
} else if (Organization.class.isAssignableFrom(partySubclass)) {
String organizationNameText = jsonNode.get(ORGANIZATION_NAME).asText();
OrganizationName organizationName = new OrganizationName(organizationNameText);
if (Company.class.isAssignableFrom(partySubclass)) {
return new Company(partyId, organizationName, roles, registeredIdentifiers, version);
} else if (OrganizationUnit.class.isAssignableFrom(partySubclass)) {
return new OrganizationUnit(partyId, organizationName, roles, registeredIdentifiers, version);
}
}
throw new IllegalStateException("Cannot deserialize Party of type: " + type);
}
}
}
Nie jest to już „ładne”. Występuje tu magia związana z refleksją, która pozwala mi znaleźć odpowiednią podklasę Party na podstawie pola type i na tej podstawie ją zainstancjonować. Szczypta dynamicznego programowania. Pewnie dałoby się to zrobić jeszcze lepiej. Pozostaje jednak pytanie, co mi to w ogóle daje?
Wydaje mi się, że ta zabawa daje mi stabilny rdzeń aplikacji. Kiedy przyjdzie potrzeba dodania kolejnej strony, muszę tylko zmienić część infrastruktury. Jest możliwe, że w wybranym biznesie tych stron nie będzie tak dużo.
Dodatkowo relacje są naprawdę dynamiczne. Nie muszę tworzyć nowych klas reprezentujących nowe relacje. Wystarczy tylko dodać nowy String reprezentujący nową relację. Tak samo ma się to do ról. Natomiast co do weryfikacji, czy dana rola może wejść w daną relację, tutaj można by podpiąć silnik reguł. Reprezentacja weryfikacji w postaci abstrakcji w kodzie byłaby stabilna, natomiast reguły mogłyby być dynamicznie zmieniane, np. w bazie danych. To też jest ciekawe ćwiczenie, które można sobie zrobić.
Warstwa utils
Na sam koniec warstwa utils, w której umieściłem kilka fabryk, które pozwalają mi tworzyć instancje klas z modelu domenowego. Tak sobie teraz myślę, że może te nazwy nie są trafione, bo jedna z nich faktycznie tworzy instancję podanej klasy. Natomiast pozostałe bardziej szukają odpowiedniej klasy. Pewnie można by do nich przenieść odpowiednią logikę, co nie powinno być trudne.
public class PartyFactory {
static final String BASE_PACKAGE = "pl.cezarysanecki.partyarchetypeapp";
static final Reflections REFLECTIONS = new Reflections(BASE_PACKAGE);
public Class<? extends Party> findSubclassBy(String type) {
Set<Class<? extends Party>> subclasses = REFLECTIONS.getSubTypesOf(Party.class);
return subclasses.stream()
.filter(subclass -> subclass.getSimpleName().equals(type))
.findFirst()
.orElse(null);
}
}
Posumowanie
To tyle, jeśli chodzi o mój eksperyment z archetypem “Party”. Mam nadzieję, że ten wpis będzie dla Ciebie ciekawostką i może zainspiruje Cię do sięgnięcia po ten archetyp w swoim projekcie. Widzę w tym dużo zalet, zwłaszcza jeśli chodzi o dynamiczne modelowanie relacji i ról. Oczywiście, jak zawsze, wszystko zależy od konkretnego przypadku użycia i wymagań biznesowych. Niemniej jednak warto mieć ten archetyp w swoim arsenale narzędzi programistycznych. Czasami jednak pewnie wystarczy sama świadomość istnienia archetypów, aby lepiej zrozumieć dany biznes i jego potrzeby. A implementacja nie musi być aż tak rozbudowana.
W moim przypadku zawsze chciałem tego spróbować, aby połączyć te dwa światy. Zwłaszcza jak w grę wchodzi refleksja. Przy okazji dobrze się przy tym bawiłem i nauczyłem kilku nowych rzeczy. Jeśli chcesz zobaczyć cały kod, to jest on dostępny na moim GitHubie pod adresem: cezarysanecki/archetypes.