chore: import upstream snapshot with attribution
@@ -0,0 +1,33 @@
|
||||
version: 2
|
||||
jobs:
|
||||
build:
|
||||
docker:
|
||||
- image: circleci/openjdk:11-jdk-stretch
|
||||
steps:
|
||||
- checkout
|
||||
|
||||
- restore_cache:
|
||||
key: maven-dependencies-{{ checksum "pom.xml" }}
|
||||
|
||||
- run:
|
||||
name: Downloading dependencies
|
||||
command: ./mvnw dependency:go-offline
|
||||
|
||||
- save_cache:
|
||||
paths:
|
||||
- ~/.m2
|
||||
key: maven-dependencies-{{ checksum "pom.xml" }}
|
||||
|
||||
- run:
|
||||
name: Building and testing
|
||||
command: ./mvnw verify
|
||||
|
||||
- store_test_results:
|
||||
path: target/surefire-reports
|
||||
|
||||
- store_test_results:
|
||||
path: target/failsafe-reports
|
||||
|
||||
- run:
|
||||
name: Uploading coverage reports
|
||||
command: bash <(curl -s https://codecov.io/bash)
|
||||
@@ -0,0 +1,26 @@
|
||||
/target/
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/build/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
@@ -0,0 +1 @@
|
||||
distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.5.3/apache-maven-3.5.3-bin.zip
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM openjdk:11.0.1-jre-slim-stretch
|
||||
EXPOSE 8080
|
||||
WORKDIR /app
|
||||
ARG JAR=library-0.0.1-SNAPSHOT.jar
|
||||
|
||||
COPY /target/$JAR /app.jar
|
||||
ENTRYPOINT ["java","-jar","/app.jar"]
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM maven:3.6-jdk-11-slim as BUILD
|
||||
COPY . /src
|
||||
WORKDIR /src
|
||||
RUN mvn install -DskipTests
|
||||
|
||||
FROM openjdk:11.0.1-jre-slim-stretch
|
||||
EXPOSE 8080
|
||||
WORKDIR /app
|
||||
ARG JAR=library-0.0.1-SNAPSHOT.jar
|
||||
|
||||
COPY --from=BUILD /src/target/$JAR /app.jar
|
||||
ENTRYPOINT ["java","-jar","/app.jar"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Jakub Pilimon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,820 @@
|
||||
[](https://circleci.com/gh/ddd-by-examples/library)
|
||||
[](https://codecov.io/gh/ddd-by-examples/library)
|
||||
|
||||
# Table of contents
|
||||
|
||||
1. [About](#about)
|
||||
2. [Domain description](#domain-description)
|
||||
3. [General assumptions](#general-assumptions)
|
||||
3.1 [Process discovery](#process-discovery)
|
||||
3.2 [Project structure and architecture](#project-structure-and-architecture)
|
||||
3.3 [Aggregates](#aggregates)
|
||||
3.4 [Events](#events)
|
||||
3.4.1 [Events in Repositories](#events-in-repositories)
|
||||
3.5 [ArchUnit](#archunit)
|
||||
3.6 [Functional thinking](#functional-thinking)
|
||||
3.7 [No ORM](#no-orm)
|
||||
3.8 [Architecture-code gap](#architecture-code-gap)
|
||||
3.9 [Model-code gap](#model-code-gap)
|
||||
3.10 [Spring](#spring)
|
||||
3.11 [Tests](#tests)
|
||||
4. [How to contribute](#how-to-contribute)
|
||||
5. [References](#references)
|
||||
|
||||
## About
|
||||
|
||||
This is a project of a library, driven by real [business requirements](#domain-description).
|
||||
We use techniques strongly connected with Domain Driven Design, Behavior-Driven Development,
|
||||
Event Storming, User Story Mapping.
|
||||
|
||||
## Domain description
|
||||
|
||||
A public library allows patrons to place books on hold at its various library branches.
|
||||
Available books can be placed on hold only by one patron at any given point in time.
|
||||
Books are either circulating or restricted, and can have retrieval or usage fees.
|
||||
A restricted book can only be held by a researcher patron. A regular patron is limited
|
||||
to five holds at any given moment, while a researcher patron is allowed an unlimited number
|
||||
of holds. An open-ended book hold is active until the patron checks out the book, at which time it
|
||||
is completed. A closed-ended book hold that is not completed within a fixed number of
|
||||
days after it was requested will expire. This check is done at the beginning of a day by
|
||||
taking a look at daily sheet with expiring holds. Only a researcher patron can request
|
||||
an open-ended hold duration. Any patron with more than two overdue checkouts at a library
|
||||
branch will get a rejection if trying a hold at that same library branch. A book can be
|
||||
checked out for up to 60 days. Check for overdue checkouts is done by taking a look at
|
||||
daily sheet with overdue checkouts. Patron interacts with his/her current holds, checkouts, etc.
|
||||
by taking a look at patron profile. Patron profile looks like a daily sheet, but the
|
||||
information there is limited to one patron and is not necessarily daily. Currently a
|
||||
patron can see current holds (not canceled nor expired) and current checkouts (including overdue).
|
||||
Also, he/she is able to hold a book and cancel a hold.
|
||||
|
||||
How actually a patron knows which books are there to lend? Library has its catalogue of
|
||||
books where books are added together with their specific instances. A specific book
|
||||
instance of a book can be added only if there is book with matching ISBN already in
|
||||
the catalogue. Book must have non-empty title and price. At the time of adding an instance
|
||||
we decide whether it will be Circulating or Restricted. This enables
|
||||
us to have book with same ISBN as circulated and restricted at the same time (for instance,
|
||||
there is a book signed by the author that we want to keep as Restricted)
|
||||
|
||||
## General assumptions
|
||||
|
||||
### Process discovery
|
||||
|
||||
The first thing we started with was domain exploration with the help of Big Picture EventStorming.
|
||||
The description you found in the previous chapter, landed on our virtual wall:
|
||||

|
||||
The EventStorming session led us to numerous discoveries, modeled with the sticky notes:
|
||||

|
||||
During the session we discovered following definitions:
|
||||

|
||||
|
||||
This made us think of real life scenarios that might happen. We discovered them described with the help of
|
||||
the **Example mapping**:
|
||||

|
||||
|
||||
This in turn became the base for our *Design Level* sessions, where we analyzed each example:
|
||||

|
||||
|
||||
Please follow the links below to get more details on each of the mentioned steps:
|
||||
- [Big Picture EventStorming](./docs/big-picture.md)
|
||||
- [Example Mapping](docs/example-mapping.md)
|
||||
- [Design Level EventStorming](docs/design-level.md)
|
||||
|
||||
### Project structure and architecture
|
||||
At the very beginning, not to overcomplicate the project, we decided to assign each bounded context
|
||||
to a separate package, which means that the system is a modular monolith. There are no obstacles, though,
|
||||
to put contexts into maven modules or finally into microservices.
|
||||
|
||||
Bounded contexts should (amongst others) introduce autonomy in the sense of architecture. Thus, each module
|
||||
encapsulating the context has its own local architecture aligned to problem complexity.
|
||||
In the case of a context, where we identified true business logic (**lending**) we introduced a domain model
|
||||
that is a simplified (for the purpose of the project) abstraction of the reality and utilized
|
||||
hexagonal architecture. In the case of a context, that during Event Storming turned out to lack any complex
|
||||
domain logic, we applied CRUD-like local architecture.
|
||||
|
||||

|
||||
|
||||
If we are talking about hexagonal architecture, it lets us separate domain and application logic from
|
||||
frameworks (and infrastructure). What do we gain with this approach? Firstly, we can unit test most important
|
||||
part of the application - **business logic** - usually without the need to stub any dependency.
|
||||
Secondly, we create ourselves an opportunity to adjust infrastructure layer without the worry of
|
||||
breaking the core functionality. In the infrastructure layer we intensively use Spring Framework
|
||||
as probably the most mature and powerful application framework with an incredible test support.
|
||||
More information about how we use Spring you will find [here](#spring).
|
||||
|
||||
As we already mentioned, the architecture was driven by Event Storming sessions. Apart from identifying
|
||||
contexts and their complexity, we could also make a decision that we separate read and write models (CQRS).
|
||||
As an example you can have a look at **Patron Profiles** and *Daily Sheets*.
|
||||
|
||||
### Aggregates
|
||||
Aggregates discovered during Event Storming sessions communicate with each other with events. There is
|
||||
a contention, though, should they be consistent immediately or eventually? As aggregates in general
|
||||
determine business boundaries, eventual consistency sounds like a better choice, but choices in software
|
||||
are never costless. Providing eventual consistency requires some infrastructural tools, like message broker
|
||||
or event store. That's why we could (and did) start with immediate consistency.
|
||||
|
||||
> Good architecture is the one which postpones all important decisions
|
||||
|
||||
... that's why we made it easy to change the consistency model, providing tests for each option, including
|
||||
basic implementations based on **DomainEvents** interface, which can be adjusted to our needs and
|
||||
toolset in future. Let's have a look at following examples:
|
||||
|
||||
* Immediate consistency
|
||||
```groovy
|
||||
def 'should synchronize Patron, Book and DailySheet with events'() {
|
||||
given:
|
||||
bookRepository.save(book)
|
||||
and:
|
||||
patronRepo.publish(patronCreated())
|
||||
when:
|
||||
patronRepo.publish(placedOnHold(book))
|
||||
then:
|
||||
patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId)
|
||||
and:
|
||||
bookReactedToPlacedOnHoldEvent()
|
||||
and:
|
||||
dailySheetIsUpdated()
|
||||
}
|
||||
|
||||
boolean bookReactedToPlacedOnHoldEvent() {
|
||||
return bookRepository.findBy(book.bookId).get() instanceof BookOnHold
|
||||
}
|
||||
|
||||
boolean dailySheetIsUpdated() {
|
||||
return new JdbcTemplate(datasource).query("select count(*) from holds_sheet s where s.hold_by_patron_id = ?",
|
||||
[patronId.patronId] as Object[],
|
||||
new ColumnMapRowMapper()).get(0)
|
||||
.get("COUNT(*)") == 1
|
||||
}
|
||||
```
|
||||
_Please note that here we are just reading from database right after events are being published_
|
||||
|
||||
Simple implementation of the event bus is based on Spring application events:
|
||||
```java
|
||||
@AllArgsConstructor
|
||||
public class JustForwardDomainEventPublisher implements DomainEvents {
|
||||
|
||||
private final ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@Override
|
||||
public void publish(DomainEvent event) {
|
||||
applicationEventPublisher.publishEvent(event);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* Eventual consistency
|
||||
```groovy
|
||||
def 'should synchronize Patron, Book and DailySheet with events'() {
|
||||
given:
|
||||
bookRepository.save(book)
|
||||
and:
|
||||
patronRepo.publish(patronCreated())
|
||||
when:
|
||||
patronRepo.publish(placedOnHold(book))
|
||||
then:
|
||||
patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId)
|
||||
and:
|
||||
bookReactedToPlacedOnHoldEvent()
|
||||
and:
|
||||
dailySheetIsUpdated()
|
||||
}
|
||||
|
||||
void bookReactedToPlacedOnHoldEvent() {
|
||||
pollingConditions.eventually {
|
||||
assert bookRepository.findBy(book.bookId).get() instanceof BookOnHold
|
||||
}
|
||||
}
|
||||
|
||||
void dailySheetIsUpdated() {
|
||||
pollingConditions.eventually {
|
||||
assert countOfHoldsInDailySheet() == 1
|
||||
}
|
||||
}
|
||||
```
|
||||
_Please note that the test looks exactly the same as previous one, but now we utilized Groovy's
|
||||
**PollingConditions** to perform asynchronous functionality tests_
|
||||
|
||||
Sample implementation of event bus is following:
|
||||
|
||||
```java
|
||||
@AllArgsConstructor
|
||||
public class StoreAndForwardDomainEventPublisher implements DomainEvents {
|
||||
|
||||
private final JustForwardDomainEventPublisher justForwardDomainEventPublisher;
|
||||
private final EventsStorage eventsStorage;
|
||||
|
||||
@Override
|
||||
public void publish(DomainEvent event) {
|
||||
eventsStorage.save(event);
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 3000L)
|
||||
@Transactional
|
||||
public void publishAllPeriodically() {
|
||||
List<DomainEvent> domainEvents = eventsStorage.toPublish();
|
||||
domainEvents.forEach(justForwardDomainEventPublisher::publish);
|
||||
eventsStorage.published(domainEvents);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To clarify, we should always aim for aggregates that can handle a business operation atomically
|
||||
(transactionally if you like), so each aggregate should be as independent and decoupled from other
|
||||
aggregates as possible. Thus, eventual consistency is promoted. As we already mentioned, it comes
|
||||
with some tradeoffs, so from the pragmatic point of view immediate consistency is also a choice.
|
||||
You might ask yourself a question now: _What if I don't have any events yet?_. Well, a pragmatic
|
||||
approach would be to encapsulate the communication between aggregates in a _Service-like_ class,
|
||||
where you could call proper aggregates line by line explicitly.
|
||||
|
||||
### Events
|
||||
Talking about inter-aggregate communication, we must remember that events reduce coupling, but don't remove
|
||||
it completely. Thus, it is very vital to share(publish) only those events, that are necessary for other
|
||||
aggregates to exist and function. Otherwise there is a threat that the level of coupling will increase
|
||||
introducing **feature envy**, because other aggregates might start using those events to perform actions
|
||||
they are not supposed to perform. A solution to this problem could be the distinction of domain events
|
||||
and integration events, which will be described here soon.
|
||||
|
||||
### Events in Repositories
|
||||
Repositories are one of the most popular design pattern. They abstract our domain model from data layer.
|
||||
In other words, they deal with state. That said, a common use-case is when we pass a new state to our repository,
|
||||
so that it gets persisted. It may look like so:
|
||||
|
||||
```java
|
||||
public class BusinessService {
|
||||
|
||||
private final PatronRepository patronRepository;
|
||||
|
||||
void businessMethod(PatronId patronId) {
|
||||
Patron patron = patronRepository.findById(patronId);
|
||||
//do sth
|
||||
patronRepository.save(patron);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Conceptually, between 1st and 3rd line of that business method we change state of our Patron from A to B.
|
||||
This change might be calculated by dirty checking or we might just override entire Patron state in the database.
|
||||
Third option is _Let's make implicit explicit_ and actually call this state change A->B an **event**.
|
||||
After all, event-driven architecture is all about promoting state changes as domain events.
|
||||
|
||||
Thanks to this our domain model may become immutable and just return events as results of invoking a command like so:
|
||||
|
||||
```java
|
||||
public BookPlacedOnHold placeOnHold(AvailableBook book) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
And our repository might operate directly on events like so:
|
||||
|
||||
```java
|
||||
public interface PatronRepository {
|
||||
void save(PatronEvent event) {
|
||||
}
|
||||
```
|
||||
|
||||
### ArchUnit
|
||||
|
||||
One of the main components of a successful project is technical leadership that lets the team go in the right
|
||||
direction. Nevertheless, there are tools that can support teams in keeping the code clean and protect the
|
||||
architecture, so that the project won't become a Big Ball of Mud, and thus will be pleasant to develop and
|
||||
to maintain. The first option, the one we proposed, is [ArchUnit](https://www.archunit.org/) - a Java architecture
|
||||
test tool. ArchUnit lets you write unit tests of your architecture, so that it is always consistent with initial
|
||||
vision. Maven modules could be an alternative as well, but let's focus on the former.
|
||||
|
||||
In terms of hexagonal architecture, it is essential to ensure, that we do not mix different levels of
|
||||
abstraction (hexagon levels):
|
||||
```java
|
||||
@ArchTest
|
||||
public static final ArchRule model_should_not_depend_on_infrastructure =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("..model..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAPackage("..infrastructure..");
|
||||
```
|
||||
and that frameworks do not affect the domain model
|
||||
```java
|
||||
@ArchTest
|
||||
public static final ArchRule model_should_not_depend_on_spring =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("..io.pillopl.library.lending..model..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAPackage("org.springframework..");
|
||||
```
|
||||
|
||||
### Functional thinking
|
||||
When you look at the code you might find a scent of functional programming. Although we do not follow
|
||||
a _clean_ FP, we try to think of business processes as pipelines or workflows, utilizing functional style through
|
||||
following concepts.
|
||||
|
||||
_Please note that this is not a reference project for FP._
|
||||
|
||||
#### Immutable objects
|
||||
Each class that represents a business concept is immutable, thanks to which we:
|
||||
* provide full encapsulation and objects' states protection,
|
||||
* secure objects for multithreaded access,
|
||||
* control all side effects much clearer.
|
||||
|
||||
#### Pure functions
|
||||
We model domain operations, discovered in Design Level Event Storming, as pure functions, and declare them in
|
||||
both domain and application layers in the form of Java's functional interfaces. Their implementations are placed
|
||||
in infrastructure layer as ordinary methods with side effects. Thanks to this approach we can follow the abstraction
|
||||
of ubiquitous language explicitly, and keep this abstraction implementation-agnostic. As an example, you could have
|
||||
a look at `FindAvailableBook` interface and its implementation:
|
||||
|
||||
```java
|
||||
@FunctionalInterface
|
||||
public interface FindAvailableBook {
|
||||
|
||||
Option<AvailableBook> findAvailableBookBy(BookId bookId);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
@AllArgsConstructor
|
||||
class BookDatabaseRepository implements FindAvailableBook {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
public Option<AvailableBook> findAvailableBookBy(BookId bookId) {
|
||||
return Match(findBy(bookId)).of(
|
||||
Case($Some($(instanceOf(AvailableBook.class))), Option::of),
|
||||
Case($(), Option::none)
|
||||
);
|
||||
}
|
||||
|
||||
Option<Book> findBy(BookId bookId) {
|
||||
return findBookById(bookId)
|
||||
.map(BookDatabaseEntity::toDomainModel);
|
||||
}
|
||||
|
||||
private Option<BookDatabaseEntity> findBookById(BookId bookId) {
|
||||
return Try
|
||||
.ofSupplier(() -> of(jdbcTemplate.queryForObject("SELECT b.* FROM book_database_entity b WHERE b.book_id = ?",
|
||||
new BeanPropertyRowMapper<>(BookDatabaseEntity.class), bookId.getBookId())))
|
||||
.getOrElse(none());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Type system
|
||||
_Type system - like_ modelling - we modelled each domain object's state discovered during EventStorming as separate
|
||||
classes: `AvailableBook`, `BookOnHold`, `CheckedOutBook`. With this approach we provide much clearer abstraction than
|
||||
having a single `Book` class with an enum-based state management. Moving the logic to these specific classes brings
|
||||
Single Responsibility Principle to a different level. Moreover, instead of checking invariants in every business method
|
||||
we leave the role to the compiler. As an example, please consider following scenario: _you can place on hold only a book
|
||||
that is currently available_. We could have done it in a following way:
|
||||
```java
|
||||
public Either<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(Book book) {
|
||||
if (book.status == AVAILABLE) {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
but we use the _type system_ and declare method of following signature
|
||||
```java
|
||||
public Either<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(AvailableBook book) {
|
||||
...
|
||||
}
|
||||
```
|
||||
The more errors we discover at compile time the better.
|
||||
|
||||
Yet another advantage of applying such type system is that we can represent business flows and state transitions
|
||||
with functions much easier. As an example, following functions:
|
||||
```
|
||||
placeOnHold: AvailableBook -> BookHoldFailed | BookPlacedOnHold
|
||||
cancelHold: BookOnHold -> BookHoldCancelingFailed | BookHoldCanceled
|
||||
```
|
||||
are much more concise and descriptive than these:
|
||||
```
|
||||
placeOnHold: Book -> BookHoldFailed | BookPlacedOnHold
|
||||
cancelHold: Book -> BookHoldCancelingFailed | BookHoldCanceled
|
||||
```
|
||||
as here we have a lot of constraints hidden within function implementations.
|
||||
|
||||
Moreover if you think of your domain as a set of operations (functions) that are being executed on business objects
|
||||
(aggregates) you don't think of any execution model (like async processing). It is fine, because you don't have to.
|
||||
Domain functions are free from I/O operations, async, and other side-effects-prone things, which are put into the
|
||||
infrastructure layer. Thanks to this, we can easily test them without mocking mentioned parts.
|
||||
|
||||
#### Monads
|
||||
Business methods might have different results. One might return a value or a `null`, throw an exception when something
|
||||
unexpected happens or just return different objects under different circumstances. All those situations are typical
|
||||
to object-oriented languages like Java, but do not fit into functional style. We are dealing with this issues
|
||||
with monads (monadic containers provided by [Vavr](https://www.vavr.io)):
|
||||
* When a method returns optional value, we use the `Option` monad:
|
||||
|
||||
```java
|
||||
Option<Book> findBy(BookId bookId) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
* When a method might return one of two possible values, we use the `Either` monad:
|
||||
|
||||
```java
|
||||
Either<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(AvailableBook book) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
* When an exception might occur, we use `Try` monad:
|
||||
|
||||
```java
|
||||
Try<Result> placeOnHold(@NonNull PlaceOnHoldCommand command) {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Thanks to this, we can follow the functional programming style, but we also enrich our domain language and
|
||||
make our code much more readable for the clients.
|
||||
|
||||
#### Pattern Matching
|
||||
Depending on a type of a given book object we often need to perform different actions. Series of if/else or switch/case statements
|
||||
could be a choice, but it is the pattern matching that provides the most conciseness and flexibility. With the code
|
||||
like below we can check numerous patterns against objects and access their constituents, so our code has a minimal dose
|
||||
of language-construct noise:
|
||||
```java
|
||||
private Book handleBookPlacedOnHold(Book book, BookPlacedOnHold bookPlacedOnHold) {
|
||||
return API.Match(book).of(
|
||||
Case($(instanceOf(AvailableBook.class)), availableBook -> availableBook.handle(bookPlacedOnHold)),
|
||||
Case($(instanceOf(BookOnHold.class)), bookOnHold -> raiseDuplicateHoldFoundEvent(bookOnHold, bookPlacedOnHold)),
|
||||
Case($(), () -> book)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### (No) ORM
|
||||
If you run `mvn dependency:tree` you won't find any JPA implementation. Although we think that ORM solutions (like Hibernate)
|
||||
are very powerful and useful, we decided not to use them, as we wouldn't utilize their features. What features are
|
||||
talking about? Lazy loading, caching, dirty checking. Why don't we need them? We want to have more control
|
||||
over SQL queries and minimize the object-relational impedance mismatch ourselves. Moreover, thanks to relatively
|
||||
small aggregates, containing as little data as it is required to protect the invariants, we don't need the
|
||||
lazy loading mechanism either.
|
||||
With Hexagonal Architecture we have the ability to separate domain and persistence models and test them
|
||||
independently. Moreover, we can also introduce different persistence strategies for different aggregates.
|
||||
In this project, we utilize both plain SQL queries and `JdbcTemplate` and use new and very promising
|
||||
project called Spring Data JDBC, that is free from the JPA-related overhead mentioned before.
|
||||
Please find below an example of a repository:
|
||||
|
||||
```java
|
||||
interface PatronEntityRepository extends CrudRepository<PatronDatabaseEntity, Long> {
|
||||
|
||||
@Query("SELECT p.* FROM patron_database_entity p where p.patron_id = :patronId")
|
||||
PatronDatabaseEntity findByPatronId(@Param("patronId") UUID patronId);
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
At the same time we propose other way of persisting aggregates, with plain SQL queries and `JdbcTemplate`:
|
||||
|
||||
```java
|
||||
@AllArgsConstructor
|
||||
class BookDatabaseRepository implements BookRepository, FindAvailableBook, FindBookOnHold {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
public Option<Book> findBy(BookId bookId) {
|
||||
return findBookById(bookId)
|
||||
.map(BookDatabaseEntity::toDomainModel);
|
||||
}
|
||||
|
||||
private Option<BookDatabaseEntity> findBookById(BookId bookId) {
|
||||
return Try
|
||||
.ofSupplier(() -> of(jdbcTemplate.queryForObject("SELECT b.* FROM book_database_entity b WHERE b.book_id = ?",
|
||||
new BeanPropertyRowMapper<>(BookDatabaseEntity.class), bookId.getBookId())))
|
||||
.getOrElse(none());
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
_Please note that despite having the ability to choose different persistence implementations for aggregates
|
||||
it is recommended to stick to one option within the app/team_
|
||||
|
||||
### Architecture-code gap
|
||||
We put a lot of attention to keep the consistency between the overall architecture (including diagrams)
|
||||
and the code structure. Having identified bounded contexts we could organize them in modules (packages, to
|
||||
be more specific). Thanks to this we gain the famous microservices' autonomy, while having a monolithic
|
||||
application. Each package has well defined public API, encapsulating all implementation details by using
|
||||
package-protected or private scopes.
|
||||
|
||||
Just by looking at the package structure:
|
||||
|
||||
```
|
||||
└── library
|
||||
├── catalogue
|
||||
├── commons
|
||||
│ ├── aggregates
|
||||
│ ├── commands
|
||||
│ └── events
|
||||
│ └── publisher
|
||||
└── lending
|
||||
├── book
|
||||
│ ├── application
|
||||
│ ├── infrastructure
|
||||
│ └── model
|
||||
├── dailysheet
|
||||
│ ├── infrastructure
|
||||
│ └── model
|
||||
├── librarybranch
|
||||
│ └── model
|
||||
├── patron
|
||||
│ ├── application
|
||||
│ ├── infrastructure
|
||||
│ └── model
|
||||
└── patronprofile
|
||||
├── infrastructure
|
||||
├── model
|
||||
└── web
|
||||
```
|
||||
you can see that the architecture is screaming that it has two bounded contexts: **catalogue**
|
||||
and **lending**. Moreover, the **lending context** is built around five business objects: **book**,
|
||||
**dailysheet**, **librarybranch**, **patron**, and **patronprofile**, while **catalogue** has no subpackages,
|
||||
which suggests that it might be a CRUD with no complex logic inside. Please find the architecture diagram
|
||||
below.
|
||||
|
||||

|
||||
|
||||
Yet another advantage of this approach comparing to packaging by layer for example is that in order to
|
||||
deliver a functionality you would usually need to do it in one package only, which is the aforementioned
|
||||
autonomy. This autonomy, then, could be transferred to the level of application as soon as we split our
|
||||
_context-packages_ into separate microservices. Following this considerations, autonomy can be given away
|
||||
to a product team that can take care of the whole business area end-to-end.
|
||||
|
||||
### Model-code gap
|
||||
In our project we do our best to reduce _model-code gap_ to bare minimum. It means we try to put equal attention
|
||||
to both the model and the code and keep them consistent. Below you will find some examples.
|
||||
|
||||
#### Placing on hold
|
||||

|
||||
|
||||
Starting with the easiest part, below you will find the model classes corresponding to depicted command and events:
|
||||
|
||||
```java
|
||||
@Value
|
||||
class PlaceOnHoldCommand {
|
||||
...
|
||||
}
|
||||
```
|
||||
```java
|
||||
@Value
|
||||
class BookPlacedOnHold implements PatronEvent {
|
||||
...
|
||||
}
|
||||
```
|
||||
```java
|
||||
@Value
|
||||
class MaximumNumberOfHoldsReached implements PatronEvent {
|
||||
...
|
||||
}
|
||||
```
|
||||
```java
|
||||
@Value
|
||||
class BookHoldFailed implements PatronEvent {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
We know it might not look impressive now, but if you have a look at the implementation of an aggregate,
|
||||
you will see that the code reflects not only the aggregate name, but also the whole scenario of `PlaceOnHold`
|
||||
command handling. Let us uncover the details:
|
||||
|
||||
```java
|
||||
public class Patron {
|
||||
|
||||
public Either<BookHoldFailed, BookPlacedOnHoldEvents> placeOnHold(AvailableBook book) {
|
||||
return placeOnHold(book, HoldDuration.openEnded());
|
||||
}
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The signature of `placeOnHold` method screams, that it is possible to place a book on hold only when it
|
||||
is available (more information about protecting invariants by compiler you will find in [Type system section](#type-system)).
|
||||
Moreover, if you try to place available book on hold it can **either** fail (`BookHoldFailed`) or produce some events -
|
||||
what events?
|
||||
|
||||
```java
|
||||
@Value
|
||||
class BookPlacedOnHoldEvents implements PatronEvent {
|
||||
@NonNull UUID eventId = UUID.randomUUID();
|
||||
@NonNull UUID patronId;
|
||||
@NonNull BookPlacedOnHold bookPlacedOnHold;
|
||||
@NonNull Option<MaximumNumberOfHoldsReached> maximumNumberOfHoldsReached;
|
||||
|
||||
@Override
|
||||
public Instant getWhen() {
|
||||
return bookPlacedOnHold.when;
|
||||
}
|
||||
|
||||
public static BookPlacedOnHoldEvents events(BookPlacedOnHold bookPlacedOnHold) {
|
||||
return new BookPlacedOnHoldEvents(bookPlacedOnHold.getPatronId(), bookPlacedOnHold, Option.none());
|
||||
}
|
||||
|
||||
public static BookPlacedOnHoldEvents events(BookPlacedOnHold bookPlacedOnHold, MaximumNumberOfHoldsReached maximumNumberOfHoldsReached) {
|
||||
return new BookPlacedOnHoldEvents(bookPlacedOnHold.patronId, bookPlacedOnHold, Option.of(maximumNumberOfHoldsReached));
|
||||
}
|
||||
|
||||
public List<DomainEvent> normalize() {
|
||||
return List.<DomainEvent>of(bookPlacedOnHold).appendAll(maximumNumberOfHoldsReached.toList());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`BookPlacedOnHoldEvents` is a container for `BookPlacedOnHold` event, and - if patron has 5 book placed on hold already -
|
||||
`MaximumNumberOfHoldsReached` (please mind the `Option` monad). You can see now how perfectly the code reflects
|
||||
the model.
|
||||
|
||||
It is not everything, though. In the picture above you can also see a big rectangular yellow card with rules (policies)
|
||||
that define the conditions that need to be fulfilled in order to get the given result. All those rules are implemented
|
||||
as functions **either** allowing or rejecting the hold:
|
||||
|
||||

|
||||
```java
|
||||
PlacingOnHoldPolicy onlyResearcherPatronsCanHoldRestrictedBooksPolicy = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> {
|
||||
if (toHold.isRestricted() && patron.isRegular()) {
|
||||
return left(Rejection.withReason("Regular patrons cannot hold restricted books"));
|
||||
}
|
||||
return right(new Allowance());
|
||||
};
|
||||
```
|
||||
|
||||

|
||||
|
||||
```java
|
||||
PlacingOnHoldPolicy overdueCheckoutsRejectionPolicy = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> {
|
||||
if (patron.overdueCheckoutsAt(toHold.getLibraryBranch()) >= OverdueCheckouts.MAX_COUNT_OF_OVERDUE_RESOURCES) {
|
||||
return left(Rejection.withReason("cannot place on hold when there are overdue checkouts"));
|
||||
}
|
||||
return right(new Allowance());
|
||||
};
|
||||
```
|
||||
|
||||

|
||||
|
||||
```java
|
||||
PlacingOnHoldPolicy regularPatronMaximumNumberOfHoldsPolicy = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> {
|
||||
if (patron.isRegular() && patron.numberOfHolds() >= PatronHolds.MAX_NUMBER_OF_HOLDS) {
|
||||
return left(Rejection.withReason("patron cannot hold more books"));
|
||||
}
|
||||
return right(new Allowance());
|
||||
};
|
||||
```
|
||||
|
||||

|
||||
|
||||
```java
|
||||
PlacingOnHoldPolicy onlyResearcherPatronsCanPlaceOpenEndedHolds = (AvailableBook toHold, Patron patron, HoldDuration holdDuration) -> {
|
||||
if (patron.isRegular() && holdDuration.isOpenEnded()) {
|
||||
return left(Rejection.withReason("regular patron cannot place open ended holds"));
|
||||
}
|
||||
return right(new Allowance());
|
||||
};
|
||||
```
|
||||
|
||||
#### Spring
|
||||
Spring Framework seems to be the most popular Java framework ever used. Unfortunately it is also quite common
|
||||
to overuse its features in the business code. What you find in this project is that the domain packages
|
||||
are fully focused on modelling business problems, and are free from any DI, which makes it easy to
|
||||
unit-test it which is invaluable in terms of code reliability and maintainability. It does not mean,
|
||||
though, that we do not use Spring Framework - we do. Below you will find some details:
|
||||
- Each bounded context has its own independent application context. It means that we removed the runtime
|
||||
coupling, which is a step towards extracting modules (and microservices). How did we do that? Let's have
|
||||
a look:
|
||||
```java
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
public class LibraryApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
new SpringApplicationBuilder()
|
||||
.parent(LibraryApplication.class)
|
||||
.child(LendingConfig.class).web(WebApplicationType.SERVLET)
|
||||
.sibling(CatalogueConfiguration.class).web(WebApplicationType.NONE)
|
||||
.run(args);
|
||||
}
|
||||
}
|
||||
```
|
||||
- As you could see above, we also try not to use component scan wherever possible. Instead we utilize
|
||||
`@Configuration` classes where we define module specific beans in the infrastructure layer. Those
|
||||
configuration classes are explicitly declared in the main application class.
|
||||
|
||||
### Tests
|
||||
Tests are written in a BDD manner, expressing stories defined with Example Mapping.
|
||||
It means we utilize both TDD and Domain Language discovered with Event Storming.
|
||||
|
||||
We also made an effort to show how to create a DSL, that enables to write
|
||||
tests as if they were sentences taken from the domain descriptions. Please
|
||||
find an example below:
|
||||
|
||||
```groovy
|
||||
def 'should make book available when hold canceled'() {
|
||||
given:
|
||||
BookDSL bookOnHold = aCirculatingBook() with anyBookId() locatedIn anyBranch() placedOnHoldBy anyPatron()
|
||||
and:
|
||||
PatronEvent.BookHoldCanceled bookHoldCanceledEvent = the bookOnHold isCancelledBy anyPatron()
|
||||
|
||||
when:
|
||||
AvailableBook availableBook = the bookOnHold reactsTo bookHoldCanceledEvent
|
||||
then:
|
||||
availableBook.bookId == bookOnHold.bookId
|
||||
availableBook.libraryBranch == bookOnHold.libraryBranchId
|
||||
availableBook.version == bookOnHold.version
|
||||
}
|
||||
```
|
||||
_Please also note the **when** block, where we manifest the fact that books react to
|
||||
cancellation event_
|
||||
|
||||
## How to contribute
|
||||
|
||||
The project is still under construction, so if you like it enough to collaborate, just let us
|
||||
know or simply create a Pull Request.
|
||||
|
||||
|
||||
## How to Build
|
||||
|
||||
### Requirements
|
||||
|
||||
* Java 11
|
||||
* Maven
|
||||
|
||||
### Quickstart
|
||||
|
||||
You can run the library app by simply typing the following:
|
||||
|
||||
```console
|
||||
$ mvn spring-boot:run
|
||||
...
|
||||
...
|
||||
2019-04-03 15:55:39.162 INFO 18957 --- [ main] o.s.b.a.e.web.EndpointLinksResolver : Exposing 2 endpoint(s) beneath base path '/actuator'
|
||||
2019-04-03 15:55:39.425 INFO 18957 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
|
||||
2019-04-03 15:55:39.428 INFO 18957 --- [ main] io.pillopl.library.LibraryApplication : Started LibraryApplication in 5.999 seconds (JVM running for 23.018)
|
||||
|
||||
```
|
||||
|
||||
### Build a Jar package
|
||||
|
||||
You can build a jar with maven like so:
|
||||
|
||||
```console
|
||||
$ mvn clean package
|
||||
...
|
||||
...
|
||||
[INFO] Building jar: /home/pczarkowski/development/spring/library/target/library-0.0.1-SNAPSHOT.jar
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
[INFO] BUILD SUCCESS
|
||||
[INFO] ------------------------------------------------------------------------
|
||||
```
|
||||
|
||||
### Build with Docker
|
||||
|
||||
If you've already built the jar file you can run:
|
||||
|
||||
```console
|
||||
docker build -t spring/library .
|
||||
```
|
||||
|
||||
Otherwise you can build the jar file using the multistage dockerfile:
|
||||
|
||||
```console
|
||||
docker build -t spring/library -f Dockerfile.build .
|
||||
```
|
||||
|
||||
Either way once built you can run it like so:
|
||||
|
||||
```console
|
||||
$ docker run -ti --rm --name spring-library -p 8080:8080 spring/library
|
||||
```
|
||||
|
||||
### Production ready metrics and visualization
|
||||
To run the application as well as Prometheus and Grafana dashboard for visualizing metrics you can run all services:
|
||||
|
||||
```console
|
||||
$ docker-compose up
|
||||
```
|
||||
|
||||
If everything goes well, you can access the following services at given location:
|
||||
* http://localhost:8080/actuator/prometheus - published Micrometer metrics
|
||||
* http://localhost:9090 - Prometheus dashboard
|
||||
* http://localhost:3000 - Grafana dashboard
|
||||
|
||||
In order to see some metrics, you must create a dashboard. Go to `Create` -> `Import` and select attached `jvm-micrometer_rev8.json`. File has been pulled from
|
||||
`https://grafana.com/grafana/dashboards/4701`.
|
||||
|
||||
Please note application will be run with `local` Spring profile to setup some initial data.
|
||||
|
||||
## References
|
||||
|
||||
1. [Introducing EventStorming](https://leanpub.com/introducing_eventstorming) by Alberto Brandolini
|
||||
2. [Domain Modelling Made Functional](https://pragprog.com/book/swdddf/domain-modeling-made-functional) by Scott Wlaschin
|
||||
3. [Software Architecture for Developers](https://softwarearchitecturefordevelopers.com) by Simon Brown
|
||||
4. [Clean Architecture](https://www.amazon.com/Clean-Architecture-Craftsmans-Software-Structure/dp/0134494164) by Robert C. Martin
|
||||
5. [Domain-Driven Design: Tackling Complexity in the Heart of Software](https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215) by Eric Evans
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`ddd-by-examples/library`
|
||||
- 原始仓库:https://github.com/ddd-by-examples/library
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,32 @@
|
||||
version: "3"
|
||||
services:
|
||||
app:
|
||||
image: spring/library:latest
|
||||
container_name: 'library'
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- "SPRING_PROFILES_ACTIVE=local"
|
||||
ports:
|
||||
- '8080:8080'
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.4.3
|
||||
container_name: 'prometheus'
|
||||
volumes:
|
||||
- ./monitoring/prometheus:/etc/prometheus/
|
||||
ports:
|
||||
- '9090:9090'
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:5.2.4
|
||||
container_name: 'grafana'
|
||||
ports:
|
||||
- '3000:3000'
|
||||
volumes:
|
||||
- ./monitoring/grafana/provisioning/:/etc/grafana/provisioning/
|
||||
env_file:
|
||||
- ./monitoring/grafana/config.monitoring
|
||||
depends_on:
|
||||
- prometheus
|
||||
@@ -0,0 +1,80 @@
|
||||
# Big Picture EventStorming
|
||||
|
||||
We started the domain exploration with sticky notes and a pen. What turned out to be the first discovery,
|
||||
was the **close-ended book holding** process:
|
||||
|
||||

|
||||
|
||||
Let's briefly walk through it:
|
||||
- A **Regular Patron** can **place a book on a close-ended hold**
|
||||
- A **Regular Patron** might **reach a maximum holds number** after a **hold is placed**
|
||||
- A **Regular Patron** can either **cancel the hold** or **check out the book**
|
||||
- While the book is **checked out** the **hold is completed** and so the **returning process** starts
|
||||
- Whenever a new day starts, we check the **daily sheet** if a hold is not hanging for too long. If so,
|
||||
the **book hold is expired**
|
||||
|
||||
On the high level, the process looks clear now, but it does not work the way, that all of us
|
||||
interpreted each word written no the sticky note similarly. That's why we established some definitions:
|
||||
|
||||

|
||||
|
||||
Similar discoveries were made around **open-ened book holding** process:
|
||||
|
||||

|
||||
|
||||
- A **Researcher Patron** can **place a book on an open-ended hold**
|
||||
- A **Researcher Patron** can either **cancel the hold** or **checkout a book**
|
||||
- While the book is **checkedout** the **hold is completed** and so the **returning process** starts
|
||||
- Within the **open-ended holding** a **hold** cannot **expire** (mind the lack of **hold expired** event)
|
||||
|
||||
All right. These two processes are very similar. The part that they have in common, and we know nothing about
|
||||
it yet is called the **book returning process**:
|
||||
|
||||

|
||||
|
||||
Here's what you see there described with words:
|
||||
- **Any Patron** can **return a book**
|
||||
- If the **checkout is overdue**, it is being unregistered as soon as the **book is returned**
|
||||
- In the moment of **returning a book** we start the process of **Fees application**
|
||||
- From the moment of **book checkout**, a patron might not return the book on time. Whenever a **new day starts**
|
||||
we check the **daily sheet** find and **register overdue checkouts**
|
||||
|
||||
Wait, but what is this **checkout**?
|
||||
|
||||

|
||||
|
||||
_- OK, now tell me what is this fee application process_
|
||||
_- Nope, it is not relevant by now, will get back to it later_
|
||||
_- But wait, why? Shouldn't you get the full picture from the storming?_
|
||||
_- Yes, but remember, the time has its cost. You always need to focus on the most relevant (at this moment) business part.
|
||||
I promise to get back to this at the next workshop._
|
||||
_- Fair enough!_
|
||||
|
||||
Fundamental question that raises now is _where do these books come from?_ Looking again at the domain description,
|
||||
we have a notion of a **catalogue**. We modelled it accordingly:
|
||||
|
||||

|
||||
|
||||
Here's what happens:
|
||||
- A **library employee** can add a book into a catalogue
|
||||
- A specific **book instance** can be **added** as well, thanks to which it can be made **available** under some not
|
||||
defined yet policy
|
||||
- Both **Book removed from catalogue** and **Book instance removed from catalogue** are marked with **hot spots**,
|
||||
as they became problematic. We left answering those problems for the future.
|
||||
|
||||
There is one interesting thing we can spot in this simple **catalogue** flow. **A book** is not the same **book** that
|
||||
we had in previous processes. To make things clear, let's have a look at new definitions:
|
||||
|
||||

|
||||
|
||||
Spotting such differences helps us in drawing linguistic boundaries, that are one of the heuristics for defining
|
||||
**bounded contexts**. From this moment on, we can assume that we have at least two **bounded contexts**:
|
||||
* **lending** - context containing all business processes logically connected with book lending, including holding,
|
||||
checkout, and return
|
||||
* **catalogue** - contexts for cataloguing books and their instances
|
||||
|
||||
__More information on bounded contexts' defining will be added soon__
|
||||
|
||||
This is more or less where the first iteration of _Big Picture EventStorming_ finished. After this phase
|
||||
we had a good understanding of how library processes work on high level, and, what is an invaluable outcome,
|
||||
we got the **ubiquitous language** including well described definitions, and initial **bounded contexts**.
|
||||
|
After Width: | Height: | Size: 446 KiB |
@@ -0,0 +1,239 @@
|
||||
# Design Level EventStorming
|
||||
|
||||
As soon as we got our examples written down, we could start digging deep into each of them, identifying key interactions
|
||||
with the system, spotting business rules and constantly refining the model. In the following sections you will
|
||||
find mentioned examples modelled with Design Level EventStorming.
|
||||
|
||||
## Holding
|
||||
### Regular patron
|
||||
|
||||
The first example is _the one when regular patron tries to place his 6th hold_:
|
||||
|
||||

|
||||
|
||||
What you can see here is that we are assuming, that a particular patron has already placed 5 books on hold.
|
||||
Next, in order to place one more, a patron needs to interact with _the system_ somehow, so this is the reason
|
||||
for placing a blue sticky note representing a command called **place on hold**. In order to make such decision,
|
||||
a patron needs to have some view of the book that can be potentially placed on hold (green sticky note).
|
||||
Because the regular patron cannot place more than 5 books on hold, we could identify a rule (rectangular yellow sticky note),
|
||||
that describes conditions that needs to be fulfilled for the **Book hold failed** event to occur.
|
||||
|
||||
Fair enough, let's go further.
|
||||
|
||||
When a **patron** tries to place on hold a book that is currently not available it should not be possible, thus resulting
|
||||
in **book hold failed** event, as it is depicted below:
|
||||
|
||||

|
||||
|
||||
Taking a look at the domain description again, we find out that each patron can have no more than 2 **overdue checkouts**.
|
||||
In such situation, every attempt to **place a book on hold** should fail:
|
||||
|
||||

|
||||
|
||||
If we are talking about **regular patrons**, what is special about them is that they are not allowed to hold a
|
||||
**restricted book**:
|
||||
|
||||

|
||||
|
||||
Second thing that is not allowed for a **regular patron** is **open-ended** hold:
|
||||

|
||||
|
||||
All right, enough with failures, let patrons lend some books, eventually:
|
||||
|
||||

|
||||
|
||||
Having in mind all previous examples, we discovered following conditions that need to be fulfilled for **patron** to
|
||||
**place a book on hold**:
|
||||
* Book must be available
|
||||
* Book must not be **restricted**
|
||||
* At the moment of placing a hold, a patron cannot have more than 4 holds
|
||||
* Patron cannot have more than 1 overdue checkout
|
||||
|
||||
And here is the last example, partially covered before:
|
||||
|
||||

|
||||
|
||||
### Researcher patron
|
||||
|
||||
In the previous part of this paragraph we focused on a *regular patron* only. Let's have a look at *researcher patron* now.
|
||||
The domain description clearly states that **any** patron with more than 2 **overdue checkouts** will get a rejection
|
||||
when trying to place book on hold. So we have it modelled:
|
||||
|
||||

|
||||
|
||||
There is also no exception in terms of holding a book that is **not available**:
|
||||
|
||||

|
||||
|
||||
The thing that differentiates **researcher patron** from a **regular** one is that he/she can place on hold a **restricted**
|
||||
book:
|
||||
|
||||

|
||||
|
||||
Last three examples depict successful holding scenarios:
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Canceling a hold
|
||||
|
||||
Any patron can cancel the hold. The unbreakable condition to be fulfilled is the one that the hold exists.
|
||||
If it is not the case **book hold cancelling failed** event occurs. What you can spot here is that now the **patron**,
|
||||
in order to cancel a hold, he/she needs to have a view of current holds (mind the **Holds view** green sticky note).
|
||||
|
||||

|
||||
|
||||
If the hold is present, then it should be possible to cancel it:
|
||||
|
||||

|
||||
|
||||
We also need to take care of the scenario when a **patron** tries to **cancel a hold** that was actually
|
||||
not placed by himself/herself:
|
||||
|
||||

|
||||
|
||||
It shouldn't be also possible to **cancel a hold** twice:
|
||||
|
||||

|
||||
|
||||
Getting back to holding-related examples, let's try to join them with hold cancellation. Each **patron** can have no more
|
||||
than five holds at a particular point in time. Thus, cancelling one of them should be enough for **patron** to **place
|
||||
on hold** another book:
|
||||
|
||||

|
||||
|
||||
## Checkout
|
||||
|
||||
Checking out is actually the essence of library functioning. **Any patron** can checkout a hold, but it is only possible
|
||||
when the **hold** exists:
|
||||
|
||||

|
||||
|
||||
It is also not allowed to checkout someone else's hold:
|
||||
|
||||

|
||||
|
||||
An example summing things up is depicted below:
|
||||
|
||||

|
||||
|
||||
A real-life scenario could be that a **patron** cancels his/her hold, and tries to check the book out:
|
||||
|
||||

|
||||
|
||||
It might also happen that a **patron** has the hold, whereas the book is missing in a library:
|
||||
|
||||

|
||||
|
||||
## Expiring a hold
|
||||
|
||||
According to the domain description, any **close-ended hold** is active until it is either checked out by **patron** or
|
||||
expired. The expiration check is done automatically by the system at the **beginning of the day**. In order to find holds
|
||||
that qualify to expiration, a system needs to have a read model of such entries. Domain description names it a **Daily sheet**
|
||||
(please mind the green sticky note)
|
||||
|
||||

|
||||
|
||||
When the book is **placed on hold** and the hold is **cancelled** before its expiration due date, it shouldn't be registered
|
||||
as expired hold:
|
||||
|
||||

|
||||
|
||||
The expiration check should mark each hold as expired only once:
|
||||
|
||||

|
||||
|
||||
## Registering overdue checkout
|
||||
|
||||
Each book can be checked out for not longer than 60 days. **Overdue checkouts** are identified on a daily basis by looking
|
||||
at the **Daily sheet** (please mind the green sticky note):
|
||||
|
||||

|
||||
|
||||
Moreover we do not expect the **returned book** to be ever registered as **overdue checkout**:
|
||||
|
||||

|
||||
|
||||
## Adding to catalogue
|
||||
|
||||
The last area of analysis is the book **catalogue**. Catalogue is a collection of books and their instances.
|
||||
A book instance can be added only when there is a book with matching ISBN already registered in the catalogue:
|
||||
|
||||

|
||||
|
||||
If this is not the case, adding a book instance into catalogue should end up with failure.
|
||||

|
||||
|
||||
|
||||
## Bounded Context Classification
|
||||
|
||||
Until now, we have already identified two **bounded contexts** - **lending contexts**, and **catalogue contexts**.
|
||||
Having in mind the domain description, and looking at the amount of discovered business rules, we can clearly see,
|
||||
that **lending context** is the one that requires a lot of attention. Comparing the business complexities of both
|
||||
contexts led us to conclusion that using **tactical building blocks** of **Domain Driven Design** and applying
|
||||
**hexagonal architecture** are a reasonable choice for **lending context** while **catalogue context** is just
|
||||
a simple **CRUD**, and applying the same local architecture would be over-engineering.
|
||||
|
||||
You may ask yourself now: __how do you know that **catalogue context** is a CRUD?__. Here's a heuristic.
|
||||
If most of the events, named as verbs in past tense, are triggered by commands, being named with the same verbs
|
||||
but as imperatives, then it means we are probably just creating, updating, or deleting an object from some database.
|
||||
Moreover, if there are no specific (or very little) business rules, then it might suggest that the essential complexity
|
||||
sourced in the business is low enough for CRUD to be well applicable.
|
||||
|
||||
## Aggregates
|
||||
|
||||
What you could see in the above examples is that we have not specified the **aggregates** that would be responsible for
|
||||
handling commands and emitting events.
|
||||
Such approach keeps us away from being steered into a particular solution/language and consequently limited from the very
|
||||
beginning. Looking at behaviours and responsibilities first lets us understand the problem better, and thus find
|
||||
a better name of the **aggregate**. In this paragraph you will see how we worked out the final aggregate model.
|
||||
|
||||
The first shot was to use **Book** as an aggregate. We are __placing **a book** on hold__, __cancelling the hold for **a book**__,
|
||||
__checking **a book** out__ - all this sentences make logical sense, and even suits linguistically:
|
||||
|
||||

|
||||
|
||||
The first question that raised, was: __What about the invariants? Do they apply to a book?__. Well, not only.
|
||||
When you take a look again at the rules that we discovered in previous paragraphs, you will see things like:
|
||||
* is the patron a **regular** one or a **researcher**?
|
||||
* is patron's maximum number of holds reached?
|
||||
* is patron's maximum number of patron's overdue checkouts reached?
|
||||
* is book available?
|
||||
* is book restricted?
|
||||
Book availability and its potential restriction (which is actually a property/characteristic) does not seem to be
|
||||
as critical as those connected with patrons. Secondly, we have more patron-related rules than book-related ones.
|
||||
|
||||
OK, but why don't we just pass the **Patron** object into **Book's** methods like:
|
||||
```java
|
||||
book.placeOnHoldBy(patron);
|
||||
```
|
||||
We could, but it is the **patron** that knows more invariants, and we do not want to let any other object to protect them.
|
||||
Here is the alternative, then:
|
||||
|
||||

|
||||
|
||||
Okay, so now in order to for example __place a hold__ we need to pass a **Book** object into a **Patron**, right?
|
||||
|
||||
```java
|
||||
patron.hold(book);
|
||||
```
|
||||
|
||||
Then, if both patron's and book's invariants pass, we would modify patron and book aggregates. But doesn't it sound like
|
||||
modifying two different aggregates in one transaction? Moreover, there is one more catch. Book's invariants (including its
|
||||
availability) are just our "best wish". Our book model is just an abstraction of the real world books to lend in a library.
|
||||
Why? Because in the real world a book that is placed on hold, might be found damaged or lost in the meantime.
|
||||
Patron's invariants are more likely to be up to date and "driven" by our system. Gauges like number of holds, overdue checkouts
|
||||
are much easier to be "real ones". This in turn means that it is okay to follow (suggested - after all) eventual consistency
|
||||
model of inter-aggregate communication. It would make our model more realistic. Classes would be smaller, and easier
|
||||
to work with and to maintain.
|
||||
|
||||
We have 2 aggregates now. We could revise the decision of *Patron* being the first aggregate to be modified, and the
|
||||
**Book** being consistent in the future (eventually). We have already concluded that the **Book** is just a nice
|
||||
projection of the real world plus patron has more invariants to drive the process. Also, these invariants are more
|
||||
likely to be relevant. It is also probably less harmful, then, to place on hold a book which is actually not available
|
||||
(and run compensation process) than let patrons place books on hold while having overdue checkouts.
|
||||
|
||||
Now the final model is following:
|
||||
|
||||

|
||||
@@ -0,0 +1,37 @@
|
||||
# Example Mapping
|
||||
|
||||
After the Big Picture EventStorming we had a high level overview of the domain. The next step could have been
|
||||
to start Design Level EventStorming right away. EventStorming, considered as a tool, can be modified
|
||||
and adjusted to current needs, thus taking different forms. We could have dug deeper into the topic during Big Picture
|
||||
session and model all possible paths and scenarios with events, policies and rules then. In that case,
|
||||
it might have been difficult to spot particular business scenarios and prioritize them, as they would be spread
|
||||
throughout the wall. The alternative that we chose to apply, was to discover those scenarios first, and model each
|
||||
of them separately with Design Level EventStorming. The intermediate step is called Example Mapping. In the following
|
||||
paragraphs, you will find results of this session.
|
||||
|
||||
_Please note that this is a simplified variation of Example Mapping, where we just group examples by business areas,
|
||||
not focusing on the rules, or use cases from the original technique_
|
||||
|
||||
## Holding
|
||||
|
||||

|
||||
|
||||
## Checkout
|
||||
|
||||

|
||||
|
||||
## Expiring hold
|
||||
|
||||

|
||||
|
||||
## Canceling hold
|
||||
|
||||

|
||||
|
||||
## Overdue checkouts
|
||||
|
||||

|
||||
|
||||
## Adding to catalogue
|
||||
|
||||

|
||||
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 819 KiB |
|
After Width: | Height: | Size: 688 KiB |
|
After Width: | Height: | Size: 971 KiB |
|
After Width: | Height: | Size: 982 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 979 KiB |
|
After Width: | Height: | Size: 739 KiB |
|
After Width: | Height: | Size: 845 KiB |
|
After Width: | Height: | Size: 916 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 833 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 870 KiB |
|
After Width: | Height: | Size: 858 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 975 KiB |
|
After Width: | Height: | Size: 927 KiB |
|
After Width: | Height: | Size: 766 KiB |
|
After Width: | Height: | Size: 749 KiB |
|
After Width: | Height: | Size: 936 KiB |
|
After Width: | Height: | Size: 964 KiB |
|
After Width: | Height: | Size: 829 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1002 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 896 KiB |
|
After Width: | Height: | Size: 757 KiB |
|
After Width: | Height: | Size: 822 KiB |
|
After Width: | Height: | Size: 758 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 861 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 773 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 257 KiB |
@@ -0,0 +1 @@
|
||||
lombok.addLombokGeneratedAnnotation = true
|
||||
@@ -0,0 +1,2 @@
|
||||
GF_SECURITY_ADMIN_PASSWORD=password
|
||||
GF_USERS_ALLOW_SIGN_UP=false
|
||||
@@ -0,0 +1,16 @@
|
||||
apiVersion: 1
|
||||
|
||||
deleteDatasources:
|
||||
- name: Prometheus
|
||||
orgId: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
orgId: 1
|
||||
url: http://prometheus:9090
|
||||
basicAuth: false
|
||||
isDefault: true
|
||||
version: 1
|
||||
editable: true
|
||||
@@ -0,0 +1,11 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'library'
|
||||
scrape_interval: 10s
|
||||
metrics_path: '/actuator/prometheus'
|
||||
static_configs:
|
||||
- targets: ['library:8080']
|
||||
labels:
|
||||
application: 'library'
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/bin/sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Maven2 Start Up Batch script
|
||||
#
|
||||
# Required ENV vars:
|
||||
# ------------------
|
||||
# JAVA_HOME - location of a JDK home dir
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# M2_HOME - location of maven2's installed home dir
|
||||
# MAVEN_OPTS - parameters passed to the Java VM when running Maven
|
||||
# e.g. to debug Maven itself, use
|
||||
# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
|
||||
# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
if [ -z "$MAVEN_SKIP_RC" ] ; then
|
||||
|
||||
if [ -f /etc/mavenrc ] ; then
|
||||
. /etc/mavenrc
|
||||
fi
|
||||
|
||||
if [ -f "$HOME/.mavenrc" ] ; then
|
||||
. "$HOME/.mavenrc"
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
# OS specific support. $var _must_ be set to either true or false.
|
||||
cygwin=false;
|
||||
darwin=false;
|
||||
mingw=false
|
||||
case "`uname`" in
|
||||
CYGWIN*) cygwin=true ;;
|
||||
MINGW*) mingw=true;;
|
||||
Darwin*) darwin=true
|
||||
# Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
|
||||
# See https://developer.apple.com/library/mac/qa/qa1170/_index.html
|
||||
if [ -z "$JAVA_HOME" ]; then
|
||||
if [ -x "/usr/libexec/java_home" ]; then
|
||||
export JAVA_HOME="`/usr/libexec/java_home`"
|
||||
else
|
||||
export JAVA_HOME="/Library/Java/Home"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$JAVA_HOME" ] ; then
|
||||
if [ -r /etc/gentoo-release ] ; then
|
||||
JAVA_HOME=`java-config --jre-home`
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$M2_HOME" ] ; then
|
||||
## resolve links - $0 may be a link to maven's home
|
||||
PRG="$0"
|
||||
|
||||
# need this for relative symlinks
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG="`dirname "$PRG"`/$link"
|
||||
fi
|
||||
done
|
||||
|
||||
saveddir=`pwd`
|
||||
|
||||
M2_HOME=`dirname "$PRG"`/..
|
||||
|
||||
# make it fully qualified
|
||||
M2_HOME=`cd "$M2_HOME" && pwd`
|
||||
|
||||
cd "$saveddir"
|
||||
# echo Using m2 at $M2_HOME
|
||||
fi
|
||||
|
||||
# For Cygwin, ensure paths are in UNIX format before anything is touched
|
||||
if $cygwin ; then
|
||||
[ -n "$M2_HOME" ] &&
|
||||
M2_HOME=`cygpath --unix "$M2_HOME"`
|
||||
[ -n "$JAVA_HOME" ] &&
|
||||
JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
|
||||
[ -n "$CLASSPATH" ] &&
|
||||
CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
|
||||
fi
|
||||
|
||||
# For Mingw, ensure paths are in UNIX format before anything is touched
|
||||
if $mingw ; then
|
||||
[ -n "$M2_HOME" ] &&
|
||||
M2_HOME="`(cd "$M2_HOME"; pwd)`"
|
||||
[ -n "$JAVA_HOME" ] &&
|
||||
JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
|
||||
# TODO classpath?
|
||||
fi
|
||||
|
||||
if [ -z "$JAVA_HOME" ]; then
|
||||
javaExecutable="`which javac`"
|
||||
if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
|
||||
# readlink(1) is not available as standard on Solaris 10.
|
||||
readLink=`which readlink`
|
||||
if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
|
||||
if $darwin ; then
|
||||
javaHome="`dirname \"$javaExecutable\"`"
|
||||
javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
|
||||
else
|
||||
javaExecutable="`readlink -f \"$javaExecutable\"`"
|
||||
fi
|
||||
javaHome="`dirname \"$javaExecutable\"`"
|
||||
javaHome=`expr "$javaHome" : '\(.*\)/bin'`
|
||||
JAVA_HOME="$javaHome"
|
||||
export JAVA_HOME
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$JAVACMD" ] ; then
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
else
|
||||
JAVACMD="`which java`"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
echo "Error: JAVA_HOME is not defined correctly." >&2
|
||||
echo " We cannot execute $JAVACMD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$JAVA_HOME" ] ; then
|
||||
echo "Warning: JAVA_HOME environment variable is not set."
|
||||
fi
|
||||
|
||||
CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
|
||||
|
||||
# traverses directory structure from process work directory to filesystem root
|
||||
# first directory with .mvn subdirectory is considered project base directory
|
||||
find_maven_basedir() {
|
||||
|
||||
if [ -z "$1" ]
|
||||
then
|
||||
echo "Path not specified to find_maven_basedir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
basedir="$1"
|
||||
wdir="$1"
|
||||
while [ "$wdir" != '/' ] ; do
|
||||
if [ -d "$wdir"/.mvn ] ; then
|
||||
basedir=$wdir
|
||||
break
|
||||
fi
|
||||
# workaround for JBEAP-8937 (on Solaris 10/Sparc)
|
||||
if [ -d "${wdir}" ]; then
|
||||
wdir=`cd "$wdir/.."; pwd`
|
||||
fi
|
||||
# end of workaround
|
||||
done
|
||||
echo "${basedir}"
|
||||
}
|
||||
|
||||
# concatenates all lines of a file
|
||||
concat_lines() {
|
||||
if [ -f "$1" ]; then
|
||||
echo "$(tr -s '\n' ' ' < "$1")"
|
||||
fi
|
||||
}
|
||||
|
||||
BASE_DIR=`find_maven_basedir "$(pwd)"`
|
||||
if [ -z "$BASE_DIR" ]; then
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
##########################################################################################
|
||||
# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
|
||||
# This allows using the maven wrapper in projects that prohibit checking in binary data.
|
||||
##########################################################################################
|
||||
if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Found .mvn/wrapper/maven-wrapper.jar"
|
||||
fi
|
||||
else
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
|
||||
fi
|
||||
jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar"
|
||||
while IFS="=" read key value; do
|
||||
case "$key" in (wrapperUrl) jarUrl="$value"; break ;;
|
||||
esac
|
||||
done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Downloading from: $jarUrl"
|
||||
fi
|
||||
wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
|
||||
|
||||
if command -v wget > /dev/null; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Found wget ... using wget"
|
||||
fi
|
||||
wget "$jarUrl" -O "$wrapperJarPath"
|
||||
elif command -v curl > /dev/null; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Found curl ... using curl"
|
||||
fi
|
||||
curl -o "$wrapperJarPath" "$jarUrl"
|
||||
else
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Falling back to using Java to download"
|
||||
fi
|
||||
javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
|
||||
if [ -e "$javaClass" ]; then
|
||||
if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo " - Compiling MavenWrapperDownloader.java ..."
|
||||
fi
|
||||
# Compiling the Java class
|
||||
("$JAVA_HOME/bin/javac" "$javaClass")
|
||||
fi
|
||||
if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
|
||||
# Running the downloader
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo " - Running MavenWrapperDownloader.java ..."
|
||||
fi
|
||||
("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
##########################################################################################
|
||||
# End of extension
|
||||
##########################################################################################
|
||||
|
||||
export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo $MAVEN_PROJECTBASEDIR
|
||||
fi
|
||||
MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin; then
|
||||
[ -n "$M2_HOME" ] &&
|
||||
M2_HOME=`cygpath --path --windows "$M2_HOME"`
|
||||
[ -n "$JAVA_HOME" ] &&
|
||||
JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
|
||||
[ -n "$CLASSPATH" ] &&
|
||||
CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
|
||||
[ -n "$MAVEN_PROJECTBASEDIR" ] &&
|
||||
MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"`
|
||||
fi
|
||||
|
||||
WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
|
||||
|
||||
exec "$JAVACMD" \
|
||||
$MAVEN_OPTS \
|
||||
-classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
|
||||
"-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
|
||||
${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
|
||||
@@ -0,0 +1,161 @@
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Maven2 Start Up Batch script
|
||||
@REM
|
||||
@REM Required ENV vars:
|
||||
@REM JAVA_HOME - location of a JDK home dir
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM M2_HOME - location of maven2's installed home dir
|
||||
@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
|
||||
@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending
|
||||
@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
|
||||
@REM e.g. to debug Maven itself, use
|
||||
@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
|
||||
@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
|
||||
@echo off
|
||||
@REM set title of command window
|
||||
title %0
|
||||
@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on'
|
||||
@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
|
||||
|
||||
@REM set %HOME% to equivalent of $HOME
|
||||
if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
|
||||
|
||||
@REM Execute a user defined script before this one
|
||||
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
|
||||
@REM check for pre script, once with legacy .bat ending and once with .cmd ending
|
||||
if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat"
|
||||
if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd"
|
||||
:skipRcPre
|
||||
|
||||
@setlocal
|
||||
|
||||
set ERROR_CODE=0
|
||||
|
||||
@REM To isolate internal variables from possible post scripts, we use another setlocal
|
||||
@setlocal
|
||||
|
||||
@REM ==== START VALIDATION ====
|
||||
if not "%JAVA_HOME%" == "" goto OkJHome
|
||||
|
||||
echo.
|
||||
echo Error: JAVA_HOME not found in your environment. >&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the >&2
|
||||
echo location of your Java installation. >&2
|
||||
echo.
|
||||
goto error
|
||||
|
||||
:OkJHome
|
||||
if exist "%JAVA_HOME%\bin\java.exe" goto init
|
||||
|
||||
echo.
|
||||
echo Error: JAVA_HOME is set to an invalid directory. >&2
|
||||
echo JAVA_HOME = "%JAVA_HOME%" >&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the >&2
|
||||
echo location of your Java installation. >&2
|
||||
echo.
|
||||
goto error
|
||||
|
||||
@REM ==== END VALIDATION ====
|
||||
|
||||
:init
|
||||
|
||||
@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
|
||||
@REM Fallback to current working directory if not found.
|
||||
|
||||
set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
|
||||
IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
|
||||
|
||||
set EXEC_DIR=%CD%
|
||||
set WDIR=%EXEC_DIR%
|
||||
:findBaseDir
|
||||
IF EXIST "%WDIR%"\.mvn goto baseDirFound
|
||||
cd ..
|
||||
IF "%WDIR%"=="%CD%" goto baseDirNotFound
|
||||
set WDIR=%CD%
|
||||
goto findBaseDir
|
||||
|
||||
:baseDirFound
|
||||
set MAVEN_PROJECTBASEDIR=%WDIR%
|
||||
cd "%EXEC_DIR%"
|
||||
goto endDetectBaseDir
|
||||
|
||||
:baseDirNotFound
|
||||
set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
|
||||
cd "%EXEC_DIR%"
|
||||
|
||||
:endDetectBaseDir
|
||||
|
||||
IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
|
||||
|
||||
@setlocal EnableExtensions EnableDelayedExpansion
|
||||
for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
|
||||
@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
|
||||
|
||||
:endReadAdditionalConfig
|
||||
|
||||
SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
|
||||
set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
|
||||
set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
|
||||
|
||||
set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar"
|
||||
FOR /F "tokens=1,2 delims==" %%A IN (%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties) DO (
|
||||
IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B
|
||||
)
|
||||
|
||||
@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
|
||||
@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
|
||||
if exist %WRAPPER_JAR% (
|
||||
echo Found %WRAPPER_JAR%
|
||||
) else (
|
||||
echo Couldn't find %WRAPPER_JAR%, downloading it ...
|
||||
echo Downloading from: %DOWNLOAD_URL%
|
||||
powershell -Command "(New-Object Net.WebClient).DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"
|
||||
echo Finished downloading %WRAPPER_JAR%
|
||||
)
|
||||
@REM End of extension
|
||||
|
||||
%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
|
||||
if ERRORLEVEL 1 goto error
|
||||
goto end
|
||||
|
||||
:error
|
||||
set ERROR_CODE=1
|
||||
|
||||
:end
|
||||
@endlocal & set ERROR_CODE=%ERROR_CODE%
|
||||
|
||||
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost
|
||||
@REM check for post script, once with legacy .bat ending and once with .cmd ending
|
||||
if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat"
|
||||
if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd"
|
||||
:skipRcPost
|
||||
|
||||
@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
|
||||
if "%MAVEN_BATCH_PAUSE%" == "on" pause
|
||||
|
||||
if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE%
|
||||
|
||||
exit /B %ERROR_CODE%
|
||||
@@ -0,0 +1,263 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>2.2.0.M6</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>io.pillopl</groupId>
|
||||
<artifactId>library</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>library</name>
|
||||
<description>Domain-Driven Design example</description>
|
||||
|
||||
<properties>
|
||||
<java.version>11</java.version>
|
||||
<spring-hateoas.version>1.0.0.BUILD-SNAPSHOT</spring-hateoas.version>
|
||||
</properties>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>central</id>
|
||||
<url>https://repo1.maven.org/maven2/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>spring-libs-snapshot</id>
|
||||
<url>https://repo.spring.io/snapshot</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>repository.spring.milestone</id>
|
||||
<name>Spring Milestone Repository</name>
|
||||
<url>https://repo.spring.io/milestone</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<pluginRepositories>
|
||||
<pluginRepository>
|
||||
<id>central</id>
|
||||
<url>https://repo1.maven.org/maven2/</url>
|
||||
</pluginRepository>
|
||||
<pluginRepository>
|
||||
<id>spring-libs-snapshot</id>
|
||||
<url>https://repo.spring.io/snapshot</url>
|
||||
</pluginRepository>
|
||||
<pluginRepository>
|
||||
<id>repository.spring.release</id>
|
||||
<name>Spring GA Repository</name>
|
||||
<url>https://repo.spring.io/plugins-release/</url>
|
||||
</pluginRepository>
|
||||
</pluginRepositories>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.vavr</groupId>
|
||||
<artifactId>vavr</artifactId>
|
||||
<version>0.9.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.data</groupId>
|
||||
<artifactId>spring-data-jdbc</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-hateoas</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.4</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit4</artifactId>
|
||||
<version>0.9.3</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.codehaus.groovy</groupId>
|
||||
<artifactId>groovy</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.spockframework</groupId>
|
||||
<artifactId>spock-spring</artifactId>
|
||||
<version>1.2-groovy-2.5</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.spockframework</groupId>
|
||||
<artifactId>spock-core</artifactId>
|
||||
<version>1.2-groovy-2.5</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>1.4.197</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>2.20</version>
|
||||
<configuration>
|
||||
<includes>
|
||||
<include>**/*Test.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>2.22.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>integration-tests</id>
|
||||
<goals>
|
||||
<goal>verify</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.gmavenplus</groupId>
|
||||
<artifactId>gmavenplus-plugin</artifactId>
|
||||
<version>1.6.2</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>compileTests</goal>
|
||||
<goal>addTestSources</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<testSources>
|
||||
<testSource>
|
||||
<directory>src/test/groovy</directory>
|
||||
<includes>
|
||||
<include>**/*.groovy</include>
|
||||
</includes>
|
||||
</testSource>
|
||||
<testSource>
|
||||
<directory>src/integration-test/groovy</directory>
|
||||
<includes>
|
||||
<include>**/*.groovy</include>
|
||||
</includes>
|
||||
</testSource>
|
||||
</testSources>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>build-helper-maven-plugin</artifactId>
|
||||
<version>1.12</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>add-integration-test-resources</id>
|
||||
<phase>generate-test-resources</phase>
|
||||
<goals>
|
||||
<goal>add-test-resource</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/integration-test/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
</resource>
|
||||
</resources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.3</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>prepare-agent-integration</id>
|
||||
<goals>
|
||||
<goal>prepare-agent-integration</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>merge-results</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>merge</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<fileSets>
|
||||
<fileSet>
|
||||
<directory>target</directory>
|
||||
<includes>
|
||||
<include>*.exec</include>
|
||||
</includes>
|
||||
</fileSet>
|
||||
</fileSets>
|
||||
<destFile>target/jacoco-all.exec</destFile>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<dataFile>target/jacoco-all.exec</dataFile>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,46 @@
|
||||
package io.pillopl.library.catalogue
|
||||
|
||||
import io.vavr.control.Option
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import spock.lang.Specification
|
||||
|
||||
import static io.pillopl.library.catalogue.BookFixture.DDD
|
||||
import static io.pillopl.library.catalogue.BookFixture.NON_PRESENT_ISBN
|
||||
import static io.pillopl.library.catalogue.BookInstance.instanceOf
|
||||
import static io.pillopl.library.catalogue.BookType.Restricted
|
||||
|
||||
@SpringBootTest(classes = CatalogueConfiguration.class)
|
||||
class CatalogueDatabaseIT extends Specification {
|
||||
|
||||
@Autowired
|
||||
CatalogueDatabase catalogueDatabase
|
||||
|
||||
def 'should be able to save and load new book'() {
|
||||
given:
|
||||
Book book = DDD
|
||||
when:
|
||||
catalogueDatabase.saveNew(book)
|
||||
and:
|
||||
Option<Book> ddd = catalogueDatabase.findBy(book.bookIsbn)
|
||||
then:
|
||||
ddd.isDefined()
|
||||
ddd.get() == book
|
||||
}
|
||||
|
||||
def 'should not load not present book'() {
|
||||
when:
|
||||
Option<Book> ddd = catalogueDatabase.findBy(NON_PRESENT_ISBN)
|
||||
then:
|
||||
ddd.isEmpty()
|
||||
}
|
||||
|
||||
def 'should save book instance'() {
|
||||
when:
|
||||
catalogueDatabase.saveNew(instanceOf(DDD, Restricted))
|
||||
then:
|
||||
noExceptionThrown()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.pillopl.library.common.events.publisher;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.pillopl.library.commons.events.DomainEvents;
|
||||
import io.pillopl.library.commons.events.publisher.JustForwardDomainEventPublisher;
|
||||
import io.pillopl.library.commons.events.publisher.MeteredDomainEventPublisher;
|
||||
import io.pillopl.library.commons.events.publisher.StoreAndForwardDomainEventPublisher;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
@Configuration
|
||||
public class DomainEventsTestConfig {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
DomainEvents domainEventsWithStorage(ApplicationEventPublisher applicationEventPublisher, MeterRegistry meterRegistry) {
|
||||
return new StoreAndForwardDomainEventPublisher(
|
||||
new MeteredDomainEventPublisher(
|
||||
new JustForwardDomainEventPublisher(applicationEventPublisher), meterRegistry),
|
||||
new InMemoryEventsStorage()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package io.pillopl.library.common.events.publisher;
|
||||
|
||||
import io.pillopl.library.commons.events.DomainEvent;
|
||||
import io.pillopl.library.commons.events.publisher.EventsStorage;
|
||||
import io.vavr.collection.List;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
|
||||
public class InMemoryEventsStorage implements EventsStorage {
|
||||
|
||||
//it's not thread safe, enough for testing
|
||||
private final java.util.List<DomainEvent> eventList = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
@Override
|
||||
synchronized public void save(DomainEvent event) {
|
||||
eventList.add(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
synchronized public List<DomainEvent> toPublish() {
|
||||
return List.ofAll(eventList);
|
||||
}
|
||||
|
||||
@Override
|
||||
synchronized public void published(List<DomainEvent> events) {
|
||||
eventList.removeAll(events.asJava());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package io.pillopl.library.common.events.publisher
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import io.pillopl.library.commons.events.DomainEvent
|
||||
import io.pillopl.library.commons.events.publisher.MeteredDomainEventPublisher
|
||||
import io.pillopl.library.lending.LendingTestContext
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import spock.lang.Specification
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
@SpringBootTest(classes = [LendingTestContext.class, DomainEventsTestConfig.class])
|
||||
class MeteredDomainEventPublisherIT extends Specification {
|
||||
|
||||
@Autowired
|
||||
MeterRegistry meterRegistry
|
||||
|
||||
@Autowired
|
||||
MeteredDomainEventPublisher publisher
|
||||
|
||||
def "should count published events"() {
|
||||
when:
|
||||
publisher.publish(new TestEvent())
|
||||
then:
|
||||
countedEvents("domain_events", "name", "TestEvent") == 1.0
|
||||
when:
|
||||
publisher.publish(new TestEvent())
|
||||
then:
|
||||
countedEvents("domain_events", "name", "TestEvent") == 2.0
|
||||
}
|
||||
|
||||
def countedEvents(String metricName, String key, String tag) {
|
||||
meterRegistry.counter(metricName, key, tag).count()
|
||||
}
|
||||
}
|
||||
|
||||
@CompileStatic
|
||||
class TestEvent implements DomainEvent {
|
||||
|
||||
@Override
|
||||
UUID getEventId() {
|
||||
return null
|
||||
}
|
||||
|
||||
@Override
|
||||
UUID getAggregateId() {
|
||||
return null
|
||||
}
|
||||
|
||||
@Override
|
||||
Instant getWhen() {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package io.pillopl.library.lending;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
@Configuration
|
||||
@Import({LendingConfig.class})
|
||||
public class LendingTestContext {
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package io.pillopl.library.lending.book.infrastructure
|
||||
|
||||
import io.pillopl.library.catalogue.BookId
|
||||
import io.pillopl.library.lending.LendingTestContext
|
||||
import io.pillopl.library.lending.book.model.AvailableBook
|
||||
import io.pillopl.library.lending.book.model.Book
|
||||
import io.pillopl.library.lending.librarybranch.model.LibraryBranchId
|
||||
import io.pillopl.library.lending.patron.model.PatronId
|
||||
import io.vavr.control.Option
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import spock.lang.Specification
|
||||
|
||||
import static io.pillopl.library.lending.book.model.BookFixture.anyBookId
|
||||
import static io.pillopl.library.lending.book.model.BookFixture.circulatingAvailableBookAt
|
||||
import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId
|
||||
|
||||
@SpringBootTest(classes = LendingTestContext.class)
|
||||
class BookDatabaseRepositoryIT extends Specification {
|
||||
|
||||
BookId bookId = anyBookId()
|
||||
LibraryBranchId libraryBranchId = anyBranch()
|
||||
PatronId patronId = anyPatronId()
|
||||
|
||||
@Autowired
|
||||
BookDatabaseRepository bookEntityRepository
|
||||
|
||||
def 'persistence in real database should work'() {
|
||||
given:
|
||||
AvailableBook availableBook = circulatingAvailableBookAt(bookId, libraryBranchId)
|
||||
when:
|
||||
bookEntityRepository.save(availableBook)
|
||||
then:
|
||||
bookIsPersistedAs(AvailableBook.class)
|
||||
}
|
||||
|
||||
void bookIsPersistedAs(Class<?> clz) {
|
||||
Book book = loadPersistedBook(bookId)
|
||||
assert book.class == clz
|
||||
}
|
||||
|
||||
Book loadPersistedBook(BookId bookId) {
|
||||
Option<Book> loaded = bookEntityRepository.findBy(bookId)
|
||||
Book book = loaded.getOrElseThrow({
|
||||
new IllegalStateException("should have been persisted")
|
||||
})
|
||||
return book
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package io.pillopl.library.lending.book.infrastructure
|
||||
|
||||
import io.pillopl.library.common.events.publisher.DomainEventsTestConfig
|
||||
import io.pillopl.library.lending.LendingTestContext
|
||||
import io.pillopl.library.lending.book.model.AvailableBook
|
||||
import io.pillopl.library.lending.book.model.BookFixture
|
||||
import io.pillopl.library.lending.book.model.BookRepository
|
||||
import io.pillopl.library.lending.librarybranch.model.LibraryBranchId
|
||||
import io.pillopl.library.lending.patron.model.HoldDuration
|
||||
import io.pillopl.library.lending.patron.model.Patron
|
||||
import io.pillopl.library.lending.patron.model.Patrons
|
||||
import io.pillopl.library.lending.patron.model.PatronId
|
||||
import io.vavr.control.Option
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import spock.lang.Specification
|
||||
import spock.util.concurrent.PollingConditions
|
||||
|
||||
import javax.sql.DataSource
|
||||
|
||||
import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.PatronCreated
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId
|
||||
import static io.pillopl.library.lending.patron.model.PatronType.Regular
|
||||
|
||||
@SpringBootTest(classes = [LendingTestContext.class, DomainEventsTestConfig.class])
|
||||
class DuplicateHoldFoundIT extends Specification {
|
||||
|
||||
PatronId patron = anyPatronId()
|
||||
PatronId anotherPatron = anyPatronId()
|
||||
|
||||
LibraryBranchId libraryBranchId = anyBranch()
|
||||
AvailableBook book = BookFixture.circulatingBook()
|
||||
|
||||
@Autowired
|
||||
Patrons patronRepo
|
||||
|
||||
@Autowired
|
||||
BookRepository bookRepository
|
||||
|
||||
@Autowired
|
||||
DataSource datasource
|
||||
|
||||
PollingConditions pollingConditions = new PollingConditions(timeout: 15)
|
||||
|
||||
def 'should react to compensation event - duplicate hold on book found'() {
|
||||
given:
|
||||
bookRepository.save(book)
|
||||
and:
|
||||
patronRepo.publish(patronCreated(patron))
|
||||
and:
|
||||
patronRepo.publish(patronCreated(anotherPatron))
|
||||
when:
|
||||
patronRepo.publish(placedOnHold(book, patron))
|
||||
and:
|
||||
patronRepo.publish(placedOnHold(book, anotherPatron))
|
||||
then:
|
||||
patronShouldBeFoundInDatabaseWithZeroBookOnHold(anotherPatron)
|
||||
|
||||
}
|
||||
|
||||
BookPlacedOnHoldEvents placedOnHold(AvailableBook book, PatronId patronId) {
|
||||
return events(bookPlacedOnHoldNow(
|
||||
book.getBookId(),
|
||||
book.type(),
|
||||
book.libraryBranch,
|
||||
patronId,
|
||||
HoldDuration.closeEnded(5)))
|
||||
}
|
||||
|
||||
PatronCreated patronCreated(PatronId patronId) {
|
||||
return PatronCreated.now(patronId, Regular)
|
||||
}
|
||||
|
||||
void patronShouldBeFoundInDatabaseWithZeroBookOnHold(PatronId patronId) {
|
||||
pollingConditions.eventually {
|
||||
Patron patron = loadPersistedPatron(patronId)
|
||||
assert patron.numberOfHolds() == 0
|
||||
}
|
||||
}
|
||||
|
||||
Patron loadPersistedPatron(PatronId patronId) {
|
||||
Option<Patron> loaded = patronRepo.findBy(patronId)
|
||||
Patron patron = loaded.getOrElseThrow({
|
||||
new IllegalStateException("should have been persisted")
|
||||
})
|
||||
return patron
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package io.pillopl.library.lending.book.infrastructure
|
||||
|
||||
import io.pillopl.library.catalogue.BookId
|
||||
import io.pillopl.library.lending.LendingTestContext
|
||||
import io.pillopl.library.lending.book.model.AvailableBook
|
||||
import io.pillopl.library.lending.book.model.BookOnHold
|
||||
import io.pillopl.library.lending.librarybranch.model.LibraryBranchId
|
||||
import io.pillopl.library.lending.patron.model.PatronEvent
|
||||
import io.pillopl.library.lending.patron.model.PatronId
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import spock.lang.Specification
|
||||
|
||||
import static io.pillopl.library.catalogue.BookType.Circulating
|
||||
import static io.pillopl.library.lending.book.model.BookFixture.anyBookId
|
||||
import static io.pillopl.library.lending.book.model.BookFixture.circulatingAvailableBookAt
|
||||
import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch
|
||||
import static io.pillopl.library.lending.patron.model.HoldDuration.closeEnded
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId
|
||||
|
||||
@SpringBootTest(classes = LendingTestContext.class)
|
||||
class FindAvailableBookInDatabaseIT extends Specification {
|
||||
|
||||
BookId bookId = anyBookId()
|
||||
LibraryBranchId libraryBranchId = anyBranch()
|
||||
PatronId patronId = anyPatronId()
|
||||
|
||||
@Autowired
|
||||
BookDatabaseRepository bookEntityRepository
|
||||
|
||||
def 'should find available book in database'() {
|
||||
given:
|
||||
AvailableBook availableBook = circulatingAvailableBookAt(bookId, libraryBranchId)
|
||||
when:
|
||||
bookEntityRepository.save(availableBook)
|
||||
then:
|
||||
bookEntityRepository.findAvailableBookBy(bookId).isDefined()
|
||||
when:
|
||||
BookOnHold bookOnHold = availableBook.handle(placedOnHold())
|
||||
and:
|
||||
bookEntityRepository.save(bookOnHold)
|
||||
then:
|
||||
bookEntityRepository.findAvailableBookBy(bookId).isEmpty()
|
||||
}
|
||||
|
||||
|
||||
PatronEvent.BookPlacedOnHold placedOnHold() {
|
||||
return events(
|
||||
bookPlacedOnHoldNow(
|
||||
bookId, Circulating,
|
||||
libraryBranchId, patronId,
|
||||
closeEnded(5)))
|
||||
.bookPlacedOnHold
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package io.pillopl.library.lending.book.infrastructure
|
||||
|
||||
import io.pillopl.library.catalogue.BookId
|
||||
import io.pillopl.library.lending.LendingTestContext
|
||||
import io.pillopl.library.lending.book.model.AvailableBook
|
||||
import io.pillopl.library.lending.book.model.BookOnHold
|
||||
import io.pillopl.library.lending.librarybranch.model.LibraryBranchId
|
||||
import io.pillopl.library.lending.patron.model.HoldDuration
|
||||
import io.pillopl.library.lending.patron.model.PatronEvent
|
||||
import io.pillopl.library.lending.patron.model.PatronId
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import spock.lang.Specification
|
||||
|
||||
import static io.pillopl.library.catalogue.BookType.Circulating
|
||||
import static io.pillopl.library.lending.book.model.BookFixture.anyBookId
|
||||
import static io.pillopl.library.lending.book.model.BookFixture.circulatingAvailableBookAt
|
||||
import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId
|
||||
|
||||
@SpringBootTest(classes = LendingTestContext.class)
|
||||
class FindBookOnHoldInDatabaseIT extends Specification {
|
||||
|
||||
BookId bookId = anyBookId()
|
||||
LibraryBranchId libraryBranchId = anyBranch()
|
||||
PatronId patronId = anyPatronId()
|
||||
|
||||
@Autowired
|
||||
BookDatabaseRepository bookEntityRepository
|
||||
|
||||
def 'should find book on hold in database'() {
|
||||
given:
|
||||
AvailableBook availableBook = circulatingAvailableBookAt(bookId, libraryBranchId)
|
||||
when:
|
||||
bookEntityRepository.save(availableBook)
|
||||
then:
|
||||
bookEntityRepository.findBookOnHold(bookId, patronId).isEmpty()
|
||||
when:
|
||||
BookOnHold bookOnHold = availableBook.handle(placedOnHoldBy(patronId))
|
||||
and:
|
||||
bookEntityRepository.save(bookOnHold)
|
||||
then:
|
||||
bookEntityRepository.findBookOnHold(bookId, patronId).isDefined()
|
||||
}
|
||||
|
||||
PatronEvent.BookPlacedOnHold placedOnHoldBy(PatronId patronId) {
|
||||
return events(bookPlacedOnHoldNow(
|
||||
bookId,
|
||||
Circulating,
|
||||
libraryBranchId,
|
||||
patronId,
|
||||
HoldDuration.closeEnded(5))).bookPlacedOnHold
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package io.pillopl.library.lending.book.infrastructure
|
||||
|
||||
import io.pillopl.library.catalogue.BookId
|
||||
import io.pillopl.library.commons.aggregates.AggregateRootIsStale
|
||||
import io.pillopl.library.commons.aggregates.Version
|
||||
import io.pillopl.library.lending.LendingTestContext
|
||||
import io.pillopl.library.lending.book.model.AvailableBook
|
||||
import io.pillopl.library.lending.book.model.Book
|
||||
import io.pillopl.library.lending.librarybranch.model.LibraryBranchId
|
||||
import io.pillopl.library.lending.patron.model.HoldDuration
|
||||
import io.pillopl.library.lending.patron.model.PatronEvent
|
||||
import io.pillopl.library.lending.patron.model.PatronId
|
||||
import io.vavr.control.Option
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import spock.lang.Specification
|
||||
|
||||
import static io.pillopl.library.catalogue.BookType.Circulating
|
||||
import static io.pillopl.library.lending.book.model.BookFixture.anyBookId
|
||||
import static io.pillopl.library.lending.book.model.BookFixture.circulatingAvailableBookAt
|
||||
import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId
|
||||
|
||||
@SpringBootTest(classes = LendingTestContext.class)
|
||||
class OptimisticLockingBookAggregateIT extends Specification {
|
||||
|
||||
BookId bookId = anyBookId()
|
||||
LibraryBranchId libraryBranchId = anyBranch()
|
||||
PatronId patronId = anyPatronId()
|
||||
|
||||
@Autowired
|
||||
BookDatabaseRepository bookEntityRepository
|
||||
|
||||
def 'persistence in real database should work'() {
|
||||
given:
|
||||
AvailableBook availableBook = circulatingAvailableBookAt(bookId, libraryBranchId)
|
||||
and:
|
||||
bookEntityRepository.save(availableBook)
|
||||
and:
|
||||
Book loaded = loadPersistedBook(bookId)
|
||||
and:
|
||||
someoneModifiedBookInTheMeantime(availableBook)
|
||||
when:
|
||||
bookEntityRepository.save(loaded)
|
||||
then:
|
||||
thrown(AggregateRootIsStale)
|
||||
loadPersistedBook(bookId).version == new Version(1)
|
||||
}
|
||||
|
||||
void someoneModifiedBookInTheMeantime(AvailableBook availableBook) {
|
||||
bookEntityRepository.save(availableBook.handle(placedOnHoldBy(anyPatronId())))
|
||||
}
|
||||
|
||||
void bookIsPersistedAs(Class<?> clz) {
|
||||
Book book = loadPersistedBook(bookId)
|
||||
assert book.class == clz
|
||||
}
|
||||
|
||||
Book loadPersistedBook(BookId bookId) {
|
||||
Option<Book> loaded = bookEntityRepository.findBy(bookId)
|
||||
Book book = loaded.getOrElseThrow({
|
||||
new IllegalStateException("should have been persisted")
|
||||
})
|
||||
return book
|
||||
}
|
||||
|
||||
PatronEvent.BookPlacedOnHold placedOnHoldBy(PatronId patronId) {
|
||||
return events(bookPlacedOnHoldNow(
|
||||
bookId,
|
||||
Circulating,
|
||||
libraryBranchId,
|
||||
patronId,
|
||||
HoldDuration.closeEnded(5)))
|
||||
.bookPlacedOnHold
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package io.pillopl.library.lending.dailysheet.infrastructure
|
||||
|
||||
import io.pillopl.library.catalogue.BookId
|
||||
import io.pillopl.library.catalogue.BookType
|
||||
import io.pillopl.library.lending.LendingTestContext
|
||||
import io.pillopl.library.lending.librarybranch.model.LibraryBranchId
|
||||
import io.pillopl.library.lending.patron.model.PatronEvent
|
||||
import io.pillopl.library.lending.patron.model.PatronId
|
||||
import io.pillopl.library.lending.patron.model.PatronType
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import spock.lang.Specification
|
||||
|
||||
import javax.sql.DataSource
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
import static io.pillopl.library.catalogue.BookType.Restricted
|
||||
import static io.pillopl.library.lending.book.model.BookFixture.anyBookId
|
||||
import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId
|
||||
import static java.time.Clock.fixed
|
||||
import static java.time.Instant.now
|
||||
import static java.time.ZoneId.systemDefault
|
||||
|
||||
@SpringBootTest(classes = LendingTestContext.class)
|
||||
class FindingHoldsInDailySheetDatabaseIT extends Specification {
|
||||
|
||||
PatronId patronId = anyPatronId()
|
||||
PatronType regular = PatronType.Regular
|
||||
LibraryBranchId libraryBranchId = anyBranch()
|
||||
BookId bookId = anyBookId()
|
||||
BookType type = Restricted
|
||||
|
||||
static final Instant TIME_OF_EXPIRE_CHECK = now()
|
||||
|
||||
@Autowired
|
||||
DataSource dataSource
|
||||
|
||||
SheetsReadModel readModel
|
||||
|
||||
def setup() {
|
||||
readModel = new SheetsReadModel(new JdbcTemplate(dataSource), fixed(TIME_OF_EXPIRE_CHECK, systemDefault()))
|
||||
}
|
||||
|
||||
def 'should find expired holds'() {
|
||||
given:
|
||||
int currentNoOfExpiredHolds = readModel.queryForHoldsToExpireSheet().count()
|
||||
when:
|
||||
readModel.handle(placedOnHold(aCloseEndedHoldTillYesterday()))
|
||||
and:
|
||||
readModel.handle(placedOnHold(aCloseEndedHoldTillTomorrow()))
|
||||
then:
|
||||
readModel.queryForHoldsToExpireSheet().count() == currentNoOfExpiredHolds + 1
|
||||
}
|
||||
|
||||
def 'handling placed on hold should de idempotent'() {
|
||||
given:
|
||||
int currentNoOfExpiredHolds = readModel.queryForHoldsToExpireSheet().count()
|
||||
and:
|
||||
PatronEvent.BookPlacedOnHold event = placedOnHold(aCloseEndedHoldTillYesterday())
|
||||
when:
|
||||
2.times { readModel.handle(event) }
|
||||
then:
|
||||
readModel.queryForHoldsToExpireSheet().count() == currentNoOfExpiredHolds + 1
|
||||
}
|
||||
|
||||
def 'should never find open-ended holds'() {
|
||||
given:
|
||||
int currentNoOfExpiredHolds = readModel.queryForHoldsToExpireSheet().count()
|
||||
when:
|
||||
readModel.handle(placedOnHold(anOpenEndedHold()))
|
||||
then:
|
||||
readModel.queryForHoldsToExpireSheet().count() == currentNoOfExpiredHolds
|
||||
}
|
||||
|
||||
def 'should never find canceled holds'() {
|
||||
given:
|
||||
int currentNoOfExpiredHolds = readModel.queryForHoldsToExpireSheet().count()
|
||||
when:
|
||||
readModel.handle(placedOnHold(aCloseEndedHoldTillYesterday()))
|
||||
and:
|
||||
readModel.handle(holdCanceled())
|
||||
then:
|
||||
readModel.queryForHoldsToExpireSheet().count() == currentNoOfExpiredHolds
|
||||
}
|
||||
|
||||
def 'should never find already expired holds'() {
|
||||
given:
|
||||
int currentNoOfExpiredHolds = readModel.queryForHoldsToExpireSheet().count()
|
||||
when:
|
||||
readModel.handle(placedOnHold(anOpenEndedHold()))
|
||||
and:
|
||||
readModel.handle(holdExpired())
|
||||
then:
|
||||
readModel.queryForHoldsToExpireSheet().count() == currentNoOfExpiredHolds
|
||||
}
|
||||
|
||||
def 'should never find already checkedOut holds'() {
|
||||
given:
|
||||
int currentNoOfExpiredHolds = readModel.queryForHoldsToExpireSheet().count()
|
||||
when:
|
||||
readModel.handle(placedOnHold(aCloseEndedHoldTillYesterday()))
|
||||
and:
|
||||
readModel.handle(bookCheckedOut())
|
||||
then:
|
||||
readModel.queryForHoldsToExpireSheet().count() == currentNoOfExpiredHolds
|
||||
}
|
||||
|
||||
|
||||
Instant aCloseEndedHoldTillTomorrow() {
|
||||
return TIME_OF_EXPIRE_CHECK.plus(Duration.ofDays(1))
|
||||
}
|
||||
|
||||
Instant aCloseEndedHoldTillYesterday() {
|
||||
return TIME_OF_EXPIRE_CHECK.minus(Duration.ofDays(1))
|
||||
}
|
||||
|
||||
Instant anOpenEndedHold() {
|
||||
return null
|
||||
}
|
||||
|
||||
PatronEvent.BookPlacedOnHold placedOnHold(Instant till) {
|
||||
return new PatronEvent.BookPlacedOnHold(
|
||||
now(),
|
||||
patronId.getPatronId(),
|
||||
bookId.getBookId(),
|
||||
type,
|
||||
libraryBranchId.getLibraryBranchId(),
|
||||
TIME_OF_EXPIRE_CHECK.minusSeconds(60000),
|
||||
till)
|
||||
}
|
||||
|
||||
PatronEvent.BookHoldCanceled holdCanceled() {
|
||||
return new PatronEvent.BookHoldCanceled(
|
||||
now(),
|
||||
patronId.getPatronId(),
|
||||
bookId.getBookId(),
|
||||
libraryBranchId.getLibraryBranchId())
|
||||
}
|
||||
|
||||
PatronEvent.BookHoldExpired holdExpired() {
|
||||
return new PatronEvent.BookHoldExpired(
|
||||
now(),
|
||||
patronId.getPatronId(),
|
||||
bookId.getBookId(),
|
||||
libraryBranchId.getLibraryBranchId())
|
||||
}
|
||||
|
||||
PatronEvent.BookCheckedOut bookCheckedOut() {
|
||||
return new PatronEvent.BookCheckedOut(
|
||||
now(),
|
||||
patronId.getPatronId(),
|
||||
bookId.getBookId(),
|
||||
type,
|
||||
libraryBranchId.getLibraryBranchId(),
|
||||
now())
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package io.pillopl.library.lending.dailysheet.infrastructure
|
||||
|
||||
import io.pillopl.library.catalogue.BookId
|
||||
import io.pillopl.library.lending.LendingTestContext
|
||||
import io.pillopl.library.lending.librarybranch.model.LibraryBranchId
|
||||
import io.pillopl.library.lending.patron.model.PatronEvent
|
||||
import io.pillopl.library.lending.patron.model.PatronId
|
||||
import io.pillopl.library.lending.patron.model.PatronType
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import spock.lang.Specification
|
||||
|
||||
import javax.sql.DataSource
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
import static io.pillopl.library.catalogue.BookType.Restricted
|
||||
import static io.pillopl.library.lending.book.model.BookFixture.anyBookId
|
||||
import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId
|
||||
import static java.time.Clock.fixed
|
||||
import static java.time.Instant.now
|
||||
import static java.time.ZoneId.systemDefault
|
||||
|
||||
@SpringBootTest(classes = LendingTestContext.class)
|
||||
class FindingOverdueCheckoutsInDailySheetDatabaseIT extends Specification {
|
||||
|
||||
PatronId patronId = anyPatronId()
|
||||
PatronType regular = PatronType.Regular
|
||||
LibraryBranchId libraryBranchId = anyBranch()
|
||||
BookId bookId = anyBookId()
|
||||
|
||||
static final Instant TIME_OF_EXPIRE_CHECK = now()
|
||||
|
||||
@Autowired
|
||||
DataSource dataSource
|
||||
|
||||
SheetsReadModel readModel
|
||||
|
||||
def setup() {
|
||||
readModel = new SheetsReadModel(new JdbcTemplate(dataSource), fixed(TIME_OF_EXPIRE_CHECK, systemDefault()))
|
||||
}
|
||||
|
||||
def 'should find overdue checkouts'() {
|
||||
given:
|
||||
int currentNoOfOverdueCheckouts = readModel.queryForCheckoutsToOverdue().count()
|
||||
when:
|
||||
readModel.handle(bookCheckedOut(tillYesterday()))
|
||||
and:
|
||||
readModel.handle(bookCheckedOut(tillTomorrow()))
|
||||
then:
|
||||
readModel.queryForCheckoutsToOverdue().count() == currentNoOfOverdueCheckouts + 1
|
||||
}
|
||||
|
||||
def 'handling bookCheckedOut should de idempotent'() {
|
||||
given:
|
||||
int currentNoOfOverdueCheckouts = readModel.queryForCheckoutsToOverdue().count()
|
||||
and:
|
||||
PatronEvent.BookCheckedOut event = bookCheckedOut(tillYesterday())
|
||||
when:
|
||||
2.times { readModel.handle(event) }
|
||||
then:
|
||||
readModel.queryForCheckoutsToOverdue().count() == currentNoOfOverdueCheckouts + 1
|
||||
}
|
||||
|
||||
def 'should never find returned books'() {
|
||||
given:
|
||||
int currentNoOfOverdueCheckouts = readModel.queryForCheckoutsToOverdue().count()
|
||||
and:
|
||||
readModel.handle(bookCheckedOut(tillTomorrow()))
|
||||
when:
|
||||
readModel.handle(bookReturned())
|
||||
then:
|
||||
readModel.queryForCheckoutsToOverdue().count() == currentNoOfOverdueCheckouts
|
||||
}
|
||||
|
||||
|
||||
Instant tillTomorrow() {
|
||||
return TIME_OF_EXPIRE_CHECK.plus(Duration.ofDays(1))
|
||||
}
|
||||
|
||||
Instant tillYesterday() {
|
||||
return TIME_OF_EXPIRE_CHECK.minus(Duration.ofDays(1))
|
||||
}
|
||||
|
||||
Instant anOpenEndedHold() {
|
||||
return null
|
||||
}
|
||||
|
||||
PatronEvent.BookPlacedOnHold placedOnHold(Instant till) {
|
||||
return new PatronEvent.BookPlacedOnHold(
|
||||
now(),
|
||||
patronId.getPatronId(),
|
||||
bookId.getBookId(),
|
||||
Restricted,
|
||||
libraryBranchId.getLibraryBranchId(),
|
||||
TIME_OF_EXPIRE_CHECK.minusSeconds(60000),
|
||||
till)
|
||||
}
|
||||
|
||||
PatronEvent.BookHoldCanceled holdCanceled() {
|
||||
return new PatronEvent.BookHoldCanceled(
|
||||
now(),
|
||||
patronId.getPatronId(),
|
||||
bookId.getBookId(),
|
||||
libraryBranchId.getLibraryBranchId())
|
||||
}
|
||||
|
||||
PatronEvent.BookHoldExpired holdExpired() {
|
||||
return new PatronEvent.BookHoldExpired(
|
||||
now(),
|
||||
patronId.getPatronId(),
|
||||
bookId.getBookId(),
|
||||
libraryBranchId.getLibraryBranchId())
|
||||
}
|
||||
|
||||
PatronEvent.BookCheckedOut bookCheckedOut(Instant till) {
|
||||
return new PatronEvent.BookCheckedOut(
|
||||
now(),
|
||||
patronId.getPatronId(),
|
||||
bookId.getBookId(),
|
||||
Restricted,
|
||||
libraryBranchId.getLibraryBranchId(),
|
||||
till)
|
||||
}
|
||||
|
||||
PatronEvent.BookReturned bookReturned() {
|
||||
return new PatronEvent.BookReturned(
|
||||
now(),
|
||||
patronId.getPatronId(),
|
||||
bookId.getBookId(),
|
||||
Restricted,
|
||||
libraryBranchId.getLibraryBranchId())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package io.pillopl.library.lending.eventspropagation
|
||||
|
||||
import io.pillopl.library.common.events.publisher.DomainEventsTestConfig
|
||||
import io.pillopl.library.lending.LendingTestContext
|
||||
import io.pillopl.library.lending.book.model.AvailableBook
|
||||
import io.pillopl.library.lending.book.model.BookFixture
|
||||
import io.pillopl.library.lending.book.model.BookOnHold
|
||||
import io.pillopl.library.lending.book.model.BookRepository
|
||||
import io.pillopl.library.lending.librarybranch.model.LibraryBranchId
|
||||
import io.pillopl.library.lending.patron.model.HoldDuration
|
||||
import io.pillopl.library.lending.patron.model.Patron
|
||||
import io.pillopl.library.lending.patron.model.Patrons
|
||||
import io.pillopl.library.lending.patron.model.PatronId
|
||||
import io.vavr.control.Option
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.jdbc.core.ColumnMapRowMapper
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import spock.lang.Specification
|
||||
import spock.util.concurrent.PollingConditions
|
||||
|
||||
import javax.sql.DataSource
|
||||
|
||||
import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.PatronCreated
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.regularPatron
|
||||
import static io.pillopl.library.lending.patron.model.PatronType.Regular
|
||||
|
||||
@SpringBootTest(classes = [LendingTestContext.class, DomainEventsTestConfig.class])
|
||||
class EventualConsistencyBetweenAggregatesAndReadModelsIT extends Specification {
|
||||
|
||||
PatronId patronId = anyPatronId()
|
||||
LibraryBranchId libraryBranchId = anyBranch()
|
||||
AvailableBook book = BookFixture.circulatingBook()
|
||||
|
||||
@Autowired
|
||||
Patrons patronRepo
|
||||
|
||||
@Autowired
|
||||
BookRepository bookRepository
|
||||
|
||||
@Autowired
|
||||
DataSource datasource
|
||||
|
||||
PollingConditions pollingConditions = new PollingConditions(timeout: 6)
|
||||
|
||||
def 'should synchronize Patron, Book and DailySheet with events'() {
|
||||
given:
|
||||
bookRepository.save(book)
|
||||
and:
|
||||
patronRepo.publish(patronCreated())
|
||||
when:
|
||||
patronRepo.publish(placedOnHold(book))
|
||||
then:
|
||||
patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId)
|
||||
and:
|
||||
bookReactedToPlacedOnHoldEvent()
|
||||
and:
|
||||
dailySheetIsUpdated()
|
||||
}
|
||||
|
||||
void bookReactedToPlacedOnHoldEvent() {
|
||||
pollingConditions.eventually {
|
||||
assert bookRepository.findBy(book.bookId).get() instanceof BookOnHold
|
||||
}
|
||||
}
|
||||
|
||||
void dailySheetIsUpdated() {
|
||||
pollingConditions.eventually {
|
||||
assert countOfHoldsInDailySheet() == 1
|
||||
}
|
||||
}
|
||||
|
||||
private Object countOfHoldsInDailySheet() {
|
||||
return new JdbcTemplate(datasource).query("select count(*) from holds_sheet s where s.hold_by_patron_id = ?",
|
||||
[patronId.patronId] as Object[],
|
||||
new ColumnMapRowMapper()).get(0)
|
||||
.get("COUNT(*)")
|
||||
}
|
||||
|
||||
BookPlacedOnHoldEvents placedOnHold(AvailableBook book) {
|
||||
return events(bookPlacedOnHoldNow(
|
||||
book.getBookId(),
|
||||
book.type(),
|
||||
book.libraryBranch,
|
||||
patronId,
|
||||
HoldDuration.closeEnded(5)))
|
||||
}
|
||||
|
||||
PatronCreated patronCreated() {
|
||||
return PatronCreated.now(patronId, Regular)
|
||||
}
|
||||
|
||||
void patronShouldBeFoundInDatabaseWithOneBookOnHold(PatronId patronId) {
|
||||
Patron patron = loadPersistedPatron(patronId)
|
||||
assert patron.numberOfHolds() == 1
|
||||
assert patron.equals(regularPatron(patronId))
|
||||
}
|
||||
|
||||
|
||||
Patron loadPersistedPatron(PatronId patronId) {
|
||||
Option<Patron> loaded = patronRepo.findBy(patronId)
|
||||
Patron patron = loaded.getOrElseThrow({
|
||||
new IllegalStateException("should have been persisted")
|
||||
})
|
||||
return patron
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package io.pillopl.library.lending.eventspropagation
|
||||
|
||||
import io.pillopl.library.lending.LendingTestContext
|
||||
import io.pillopl.library.lending.book.model.AvailableBook
|
||||
import io.pillopl.library.lending.book.model.BookFixture
|
||||
import io.pillopl.library.lending.book.model.BookOnHold
|
||||
import io.pillopl.library.lending.book.model.BookRepository
|
||||
import io.pillopl.library.lending.librarybranch.model.LibraryBranchId
|
||||
import io.pillopl.library.lending.patron.model.HoldDuration
|
||||
import io.pillopl.library.lending.patron.model.Patron
|
||||
import io.pillopl.library.lending.patron.model.Patrons
|
||||
import io.pillopl.library.lending.patron.model.PatronId
|
||||
import io.vavr.control.Option
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.jdbc.core.ColumnMapRowMapper
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import spock.lang.Specification
|
||||
|
||||
import javax.sql.DataSource
|
||||
|
||||
import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.PatronCreated
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.regularPatron
|
||||
import static io.pillopl.library.lending.patron.model.PatronType.Regular
|
||||
|
||||
@SpringBootTest(classes = LendingTestContext.class)
|
||||
class StrongConsistencyBetweenAggregatesAndReadModelsIT extends Specification {
|
||||
|
||||
PatronId patronId = anyPatronId()
|
||||
LibraryBranchId libraryBranchId = anyBranch()
|
||||
AvailableBook book = BookFixture.circulatingBook()
|
||||
|
||||
@Autowired
|
||||
Patrons patronRepo
|
||||
|
||||
@Autowired
|
||||
BookRepository bookRepository
|
||||
|
||||
@Autowired
|
||||
DataSource datasource
|
||||
|
||||
def 'should synchronize Patron, Book and DailySheet with events'() {
|
||||
given:
|
||||
bookRepository.save(book)
|
||||
and:
|
||||
patronRepo.publish(patronCreated())
|
||||
when:
|
||||
patronRepo.publish(placedOnHold(book))
|
||||
then:
|
||||
patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId)
|
||||
and:
|
||||
bookReactedToPlacedOnHoldEvent()
|
||||
and:
|
||||
dailySheetIsUpdated()
|
||||
}
|
||||
|
||||
boolean bookReactedToPlacedOnHoldEvent() {
|
||||
return bookRepository.findBy(book.bookId).get() instanceof BookOnHold
|
||||
}
|
||||
|
||||
boolean dailySheetIsUpdated() {
|
||||
return new JdbcTemplate(datasource).query("select count(*) from holds_sheet s where s.hold_by_patron_id = ?",
|
||||
[patronId.patronId] as Object[],
|
||||
new ColumnMapRowMapper()).get(0)
|
||||
.get("COUNT(*)") == 1
|
||||
}
|
||||
|
||||
BookPlacedOnHoldEvents placedOnHold(AvailableBook book) {
|
||||
return events(bookPlacedOnHoldNow(
|
||||
book.bookId,
|
||||
book.type(),
|
||||
book.libraryBranch,
|
||||
patronId,
|
||||
HoldDuration.closeEnded(5)))
|
||||
}
|
||||
|
||||
PatronCreated patronCreated() {
|
||||
return PatronCreated.now(patronId, Regular)
|
||||
}
|
||||
|
||||
void patronShouldBeFoundInDatabaseWithOneBookOnHold(PatronId patronId) {
|
||||
Patron patron = loadPersistedPatron(patronId)
|
||||
assert patron.numberOfHolds() == 1
|
||||
assert patron.equals(regularPatron(patronId))
|
||||
}
|
||||
|
||||
|
||||
Patron loadPersistedPatron(PatronId patronId) {
|
||||
Option<Patron> loaded = patronRepo.findBy(patronId)
|
||||
Patron patron = loaded.getOrElseThrow({
|
||||
new IllegalStateException("should have been persisted")
|
||||
})
|
||||
return patron
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package io.pillopl.library.lending.patron.infrastructure
|
||||
|
||||
|
||||
import io.pillopl.library.lending.LendingTestContext
|
||||
import io.pillopl.library.lending.librarybranch.model.LibraryBranchId
|
||||
import io.pillopl.library.lending.patron.model.HoldDuration
|
||||
import io.pillopl.library.lending.patron.model.Patron
|
||||
import io.pillopl.library.lending.patron.model.Patrons
|
||||
import io.pillopl.library.lending.patron.model.PatronId
|
||||
import io.pillopl.library.lending.patron.model.PatronType
|
||||
import io.vavr.control.Option
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import spock.lang.Specification
|
||||
|
||||
import static io.pillopl.library.catalogue.BookType.Circulating
|
||||
import static io.pillopl.library.lending.book.model.BookFixture.anyBookId
|
||||
import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHold.bookPlacedOnHoldNow
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.BookPlacedOnHoldEvents.events
|
||||
import static io.pillopl.library.lending.patron.model.PatronEvent.PatronCreated
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.regularPatron
|
||||
import static io.pillopl.library.lending.patron.model.PatronType.Regular
|
||||
|
||||
@SpringBootTest(classes = LendingTestContext.class)
|
||||
class PatronDatabaseRepositoryIT extends Specification {
|
||||
|
||||
PatronId patronId = anyPatronId()
|
||||
PatronType regular = Regular
|
||||
LibraryBranchId libraryBranchId = anyBranch()
|
||||
|
||||
@Autowired
|
||||
Patrons patronRepo
|
||||
|
||||
def 'persistence in real database should work'() {
|
||||
when:
|
||||
patronRepo.publish(patronCreated())
|
||||
then:
|
||||
patronShouldBeFoundInDatabaseWithZeroBooksOnHold(patronId)
|
||||
when:
|
||||
patronRepo.publish(placedOnHold())
|
||||
then:
|
||||
patronShouldBeFoundInDatabaseWithOneBookOnHold(patronId)
|
||||
}
|
||||
|
||||
BookPlacedOnHoldEvents placedOnHold() {
|
||||
return events(bookPlacedOnHoldNow(
|
||||
anyBookId(),
|
||||
Circulating,
|
||||
libraryBranchId,
|
||||
patronId,
|
||||
HoldDuration.closeEnded(5)))
|
||||
}
|
||||
|
||||
PatronCreated patronCreated() {
|
||||
return PatronCreated.now(patronId, Regular)
|
||||
}
|
||||
|
||||
void patronShouldBeFoundInDatabaseWithOneBookOnHold(PatronId patronId) {
|
||||
Patron patron = loadPersistedPatron(patronId)
|
||||
assert patron.numberOfHolds() == 1
|
||||
assertPatronInformation(patron, patronId)
|
||||
}
|
||||
|
||||
void patronShouldBeFoundInDatabaseWithZeroBooksOnHold(PatronId patronId) {
|
||||
Patron patron = loadPersistedPatron(patronId)
|
||||
assert patron.numberOfHolds() == 0
|
||||
assertPatronInformation(patron, patronId)
|
||||
|
||||
}
|
||||
|
||||
void assertPatronInformation(Patron patron, PatronId patronId) {
|
||||
assert patron.equals(regularPatron(patronId))
|
||||
}
|
||||
|
||||
Patron loadPersistedPatron(PatronId patronId) {
|
||||
Option<Patron> loaded = patronRepo.findBy(patronId)
|
||||
Patron patron = loaded.getOrElseThrow({
|
||||
new IllegalStateException("should have been persisted")
|
||||
})
|
||||
return patron
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package io.pillopl.library.lending.patronprofile.infrastructure
|
||||
|
||||
import io.pillopl.library.catalogue.BookId
|
||||
import io.pillopl.library.catalogue.BookType
|
||||
import io.pillopl.library.lending.LendingTestContext
|
||||
import io.pillopl.library.lending.dailysheet.model.DailySheet
|
||||
import io.pillopl.library.lending.librarybranch.model.LibraryBranchId
|
||||
import io.pillopl.library.lending.patron.model.PatronEvent
|
||||
import io.pillopl.library.lending.patron.model.PatronId
|
||||
import io.pillopl.library.lending.patron.model.PatronType
|
||||
import io.pillopl.library.lending.patronprofile.model.Checkout
|
||||
import io.pillopl.library.lending.patronprofile.model.Hold
|
||||
import io.pillopl.library.lending.patronprofile.model.PatronProfile
|
||||
import io.pillopl.library.lending.patronprofile.model.PatronProfiles
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import spock.lang.Specification
|
||||
|
||||
import javax.sql.DataSource
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
import static io.pillopl.library.catalogue.BookType.Restricted
|
||||
import static io.pillopl.library.lending.book.model.BookFixture.anyBookId
|
||||
import static io.pillopl.library.lending.librarybranch.model.LibraryBranchFixture.anyBranch
|
||||
import static io.pillopl.library.lending.patron.model.PatronFixture.anyPatronId
|
||||
import static java.time.Instant.now
|
||||
|
||||
@SpringBootTest(classes = LendingTestContext.class)
|
||||
class FindingPatronProfileInDatabaseIT extends Specification {
|
||||
|
||||
PatronId patronId = anyPatronId()
|
||||
PatronType regular = PatronType.Regular
|
||||
LibraryBranchId libraryBranchId = anyBranch()
|
||||
BookId bookId = anyBookId()
|
||||
BookType type = Restricted
|
||||
|
||||
static final Instant TOMORROW = now().plus(Duration.ofDays(1))
|
||||
|
||||
@Autowired
|
||||
DataSource dataSource
|
||||
|
||||
@Autowired
|
||||
DailySheet dailySheet
|
||||
|
||||
@Autowired
|
||||
DataSource jdbcTemplate
|
||||
|
||||
PatronProfiles patronProfiles;
|
||||
|
||||
def setup() {
|
||||
patronProfiles = new PatronProfileReadModel(new JdbcTemplate(dataSource))
|
||||
}
|
||||
|
||||
def 'should create patron profile'() {
|
||||
when:
|
||||
PatronProfile profile = createProfile()
|
||||
then:
|
||||
thereIsZeroHoldsAndZeroCheckouts(profile)
|
||||
when:
|
||||
dailySheet.handle(placedOnHoldTill(TOMORROW))
|
||||
profile = createProfile()
|
||||
then:
|
||||
thereIsOnlyOneHold(profile)
|
||||
when:
|
||||
dailySheet.handle(bookCheckedOutTill(TOMORROW))
|
||||
profile = createProfile()
|
||||
then:
|
||||
thereIsOnlyOneCheckout(profile)
|
||||
when:
|
||||
dailySheet.handle(bookReturned())
|
||||
profile = createProfile()
|
||||
then:
|
||||
thereIsZeroHoldsAndZeroCheckouts(profile)
|
||||
|
||||
}
|
||||
|
||||
private PatronProfile createProfile() {
|
||||
PatronProfile profile
|
||||
profile = patronProfiles.fetchFor(patronId)
|
||||
profile
|
||||
}
|
||||
|
||||
void thereIsOnlyOneHold(PatronProfile profile) {
|
||||
assert profile.holdsView.currentHolds.size() == 1
|
||||
assert profile.holdsView.currentHolds.get(0) == new Hold(bookId, TOMORROW)
|
||||
assert profile.currentCheckouts.currentCheckouts.size() == 0
|
||||
}
|
||||
|
||||
void thereIsOnlyOneCheckout(PatronProfile profile) {
|
||||
assert profile.holdsView.currentHolds.size() == 0
|
||||
assert profile.currentCheckouts.currentCheckouts.size() == 1
|
||||
assert profile.currentCheckouts.currentCheckouts.get(0) == new Checkout(bookId, TOMORROW)
|
||||
}
|
||||
|
||||
void thereIsZeroHoldsAndZeroCheckouts(PatronProfile profile) {
|
||||
assert profile.holdsView.currentHolds.size() == 0
|
||||
assert profile.currentCheckouts.currentCheckouts.size() == 0
|
||||
|
||||
}
|
||||
|
||||
PatronEvent.BookCheckedOut bookCheckedOutTill(Instant till) {
|
||||
return new PatronEvent.BookCheckedOut(
|
||||
now(),
|
||||
patronId.getPatronId(),
|
||||
bookId.getBookId(),
|
||||
Restricted,
|
||||
libraryBranchId.getLibraryBranchId(),
|
||||
till)
|
||||
}
|
||||
|
||||
|
||||
PatronEvent.BookPlacedOnHold placedOnHoldTill(Instant till) {
|
||||
return new PatronEvent.BookPlacedOnHold(
|
||||
now(),
|
||||
patronId.getPatronId(),
|
||||
bookId.getBookId(),
|
||||
type,
|
||||
libraryBranchId.getLibraryBranchId(),
|
||||
now().minusSeconds(60000),
|
||||
till)
|
||||
}
|
||||
|
||||
PatronEvent.BookReturned bookReturned() {
|
||||
return new PatronEvent.BookReturned(
|
||||
now(),
|
||||
patronId.getPatronId(),
|
||||
bookId.getBookId(),
|
||||
Restricted,
|
||||
libraryBranchId.getLibraryBranchId())
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package io.pillopl.library.lending.patronprofile.web;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.pillopl.library.catalogue.BookId;
|
||||
import io.pillopl.library.lending.LendingTestContext;
|
||||
import io.pillopl.library.lending.book.model.BookFixture;
|
||||
import io.pillopl.library.lending.patron.application.hold.CancelingHold;
|
||||
import io.pillopl.library.lending.patron.application.hold.PlacingOnHold;
|
||||
import io.pillopl.library.lending.patron.model.PatronFixture;
|
||||
import io.pillopl.library.lending.patron.model.PatronId;
|
||||
import io.pillopl.library.lending.patronprofile.model.Checkout;
|
||||
import io.pillopl.library.lending.patronprofile.model.CheckoutsView;
|
||||
import io.pillopl.library.lending.patronprofile.model.Hold;
|
||||
import io.pillopl.library.lending.patronprofile.model.HoldsView;
|
||||
import io.pillopl.library.lending.patronprofile.model.PatronProfile;
|
||||
import io.pillopl.library.lending.patronprofile.model.PatronProfiles;
|
||||
import io.vavr.control.Try;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.junit4.SpringRunner;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
import static io.pillopl.library.commons.commands.Result.Success;
|
||||
import static io.vavr.collection.List.of;
|
||||
import static java.time.Instant.now;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@WebMvcTest(PatronProfileController.class)
|
||||
@ContextConfiguration(classes = {LendingTestContext.class})
|
||||
public class PatronProfileControllerIT {
|
||||
|
||||
PatronId patronId = PatronFixture.anyPatronId();
|
||||
BookId bookId = BookFixture.anyBookId();
|
||||
BookId anotherBook = BookFixture.anyBookId();
|
||||
|
||||
Instant anyDate = now();
|
||||
Instant anotherDate = now().plusSeconds(100);
|
||||
|
||||
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@MockBean
|
||||
private PatronProfiles patronProfiles;
|
||||
|
||||
@MockBean
|
||||
private PlacingOnHold placingOnHold;
|
||||
|
||||
@MockBean
|
||||
private CancelingHold cancelingHold;
|
||||
|
||||
@MockBean
|
||||
private MeterRegistry meterRegistry;
|
||||
|
||||
@Test
|
||||
public void shouldContainPatronProfileResourceWithCorrectHeadersAndLinksToCheckoutsAndHolds() throws Exception {
|
||||
given(patronProfiles.fetchFor(patronId)).willReturn(profiles());
|
||||
|
||||
//expect
|
||||
mvc.perform(get("/profiles/" + patronId.getPatronId())
|
||||
.accept(MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string(CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(jsonPath("$._links.self.href", containsString("profiles/" + patronId.getPatronId())))
|
||||
.andExpect(jsonPath("$.patronId", is(patronId.getPatronId().toString())))
|
||||
.andExpect(jsonPath("$._links.holds.href", containsString("/profiles/" + patronId.getPatronId() + "/holds")))
|
||||
.andExpect(jsonPath("$._links.checkouts.href", containsString("/profiles/" + patronId.getPatronId() + "/checkouts")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateLinksForHolds() throws Exception {
|
||||
given(patronProfiles.fetchFor(patronId)).willReturn(profiles());
|
||||
|
||||
//expect
|
||||
mvc.perform(get("/profiles/" + patronId.getPatronId() + "/holds/")
|
||||
.accept(MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string(CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(jsonPath("$._embedded.holdList[0].bookId", is(bookId.getBookId().toString())))
|
||||
.andExpect(jsonPath("$._embedded.holdList[0]._links.self.href", containsString("/profiles/" + patronId.getPatronId() + "/holds/" + bookId.getBookId())))
|
||||
.andExpect(jsonPath("$._embedded.holdList[0].till", is(anyDate.toString())))
|
||||
.andExpect(jsonPath("$._embedded.holdList[0]._templates.default.method", is("delete")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateLinksForCheckouts() throws Exception {
|
||||
given(patronProfiles.fetchFor(patronId)).willReturn(profiles());
|
||||
|
||||
//expect
|
||||
mvc.perform(get("/profiles/" + patronId.getPatronId() + "/checkouts/")
|
||||
.accept(MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string(CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(jsonPath("$._embedded.checkoutList[0].bookId", is(anotherBook.getBookId().toString())))
|
||||
.andExpect(jsonPath("$._embedded.checkoutList[0].till", is(anotherDate.toString())))
|
||||
.andExpect(jsonPath("$._embedded.checkoutList[0]._links.self.href", containsString("/profiles/" + patronId.getPatronId() + "/checkouts/" + anotherBook.getBookId())));
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturn404WhenThereIsNoHold() throws Exception {
|
||||
given(patronProfiles.fetchFor(patronId)).willReturn(profiles());
|
||||
|
||||
//expect
|
||||
mvc.perform(get("/profiles/" + patronId.getPatronId() + "/holds/" + UUID.randomUUID())
|
||||
.accept(MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturn404WhenThereIsNoCheckout() throws Exception {
|
||||
given(patronProfiles.fetchFor(patronId)).willReturn(profiles());
|
||||
|
||||
//expect
|
||||
mvc.perform(get("/profiles/" + patronId.getPatronId() + "/checkouts/" + UUID.randomUUID())
|
||||
.accept(MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturnResourceForHold() throws Exception {
|
||||
given(patronProfiles.fetchFor(patronId)).willReturn(profiles());
|
||||
|
||||
//expect
|
||||
mvc.perform(get("/profiles/" + patronId.getPatronId() + "/holds/" + bookId.getBookId())
|
||||
.accept(MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string(CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(jsonPath("$.bookId", is(bookId.getBookId().toString())))
|
||||
.andExpect(jsonPath("$.till", is(anyDate.toString())))
|
||||
.andExpect(jsonPath("$._templates.default.method", is("delete")))
|
||||
.andExpect(jsonPath("$._links.self.href", containsString("profiles/" + patronId.getPatronId() + "/holds/" + bookId.getBookId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturnResourceForCheckout() throws Exception {
|
||||
given(patronProfiles.fetchFor(patronId)).willReturn(profiles());
|
||||
|
||||
//expect
|
||||
mvc.perform(get("/profiles/" + patronId.getPatronId() + "/checkouts/" + anotherBook.getBookId())
|
||||
.accept(MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string(CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(jsonPath("$.bookId", is(anotherBook.getBookId().toString())))
|
||||
.andExpect(jsonPath("$.till", is(anotherDate.toString())))
|
||||
.andExpect(jsonPath("$._links.self.href", containsString("profiles/" + patronId.getPatronId() + "/checkouts/" + anotherBook.getBookId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldPlaceBookOnHold() throws Exception {
|
||||
given(placingOnHold.placeOnHold(any())).willReturn(Try.success(Success));
|
||||
var request = "{\"bookId\":\"6e1dfec5-5cfe-487e-814e-d70114f5396e\", \"libraryBranchId\":\"a518e2ef-5f6c-43e3-a7fc-5d895e15be3a\",\"numberOfDays\":1}";
|
||||
|
||||
// expect
|
||||
mvc.perform(post("/profiles/" + patronId.getPatronId() + "/holds")
|
||||
.accept(MediaTypes.HAL_FORMS_JSON_VALUE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(request))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturn500IfSomethingFailedWhileDuringPlacingOnHold() throws Exception {
|
||||
given(placingOnHold.placeOnHold(any())).willReturn(Try.failure(new IllegalArgumentException()));
|
||||
var request = "{\"bookId\":\"6e1dfec5-5cfe-487e-814e-d70114f5396e\", \"libraryBranchId\":\"a518e2ef-5f6c-43e3-a7fc-5d895e15be3a\",\"numberOfDays\":1}";
|
||||
|
||||
// expect
|
||||
mvc.perform(post("/profiles/" + patronId.getPatronId() + "/holds")
|
||||
.accept(MediaTypes.HAL_FORMS_JSON_VALUE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(request))
|
||||
.andExpect(status().isInternalServerError());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCancelExistingHold() throws Exception {
|
||||
given(patronProfiles.fetchFor(patronId)).willReturn(profiles());
|
||||
given(cancelingHold.cancelHold(any())).willReturn(Try.success(Success));
|
||||
|
||||
//expect
|
||||
mvc.perform(delete("/profiles/" + patronId.getPatronId() + "/holds/" + bookId.getBookId())
|
||||
.accept(MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotCancelNotExistingHold() throws Exception {
|
||||
given(patronProfiles.fetchFor(patronId)).willReturn(profiles());
|
||||
given(cancelingHold.cancelHold(any())).willReturn(Try.failure(new IllegalArgumentException()));
|
||||
|
||||
//expect
|
||||
mvc.perform(delete("/profiles/" + patronId.getPatronId() + "/holds/" + bookId.getBookId())
|
||||
.accept(MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturn500IfSomethingFailedWhileCanceling() throws Exception {
|
||||
given(patronProfiles.fetchFor(patronId)).willReturn(profiles());
|
||||
given(cancelingHold.cancelHold(any())).willReturn(Try.failure(new IllegalStateException()));
|
||||
|
||||
//expect
|
||||
mvc.perform(delete("/profiles/" + patronId.getPatronId() + "/holds/" + bookId.getBookId())
|
||||
.accept(MediaTypes.HAL_FORMS_JSON_VALUE))
|
||||
.andExpect(status().is(500));
|
||||
}
|
||||
|
||||
|
||||
PatronProfile profiles() {
|
||||
return new PatronProfile(
|
||||
new HoldsView(of(new Hold(bookId, anyDate))),
|
||||
new CheckoutsView(of(new Checkout(anotherBook, anotherDate))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package io.pillopl.library;
|
||||
|
||||
import io.pillopl.library.catalogue.CatalogueConfiguration;
|
||||
import io.pillopl.library.lending.LendingConfig;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration(exclude = DataSourceAutoConfiguration.class)
|
||||
public class LibraryApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
new SpringApplicationBuilder()
|
||||
.parent(LibraryApplication.class)
|
||||
.child(LendingConfig.class).web(WebApplicationType.SERVLET)
|
||||
.sibling(CatalogueConfiguration.class).web(WebApplicationType.NONE)
|
||||
.run(args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package io.pillopl.library.catalogue;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NonNull;
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
@EqualsAndHashCode(of = "bookIsbn")
|
||||
@AllArgsConstructor(access = AccessLevel.PACKAGE)
|
||||
class Book {
|
||||
|
||||
@NonNull
|
||||
private ISBN bookIsbn;
|
||||
@NonNull
|
||||
private Title title;
|
||||
@NonNull
|
||||
private Author author;
|
||||
|
||||
Book(String isbn, String author, String title) {
|
||||
this(new ISBN(isbn), new Title(title), new Author(author));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Value
|
||||
class Title {
|
||||
|
||||
@NonNull String title;
|
||||
|
||||
Title(String title) {
|
||||
if (title.isEmpty()) {
|
||||
throw new IllegalArgumentException("Title cannot be empty");
|
||||
}
|
||||
this.title = title.trim();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Value
|
||||
class Author {
|
||||
|
||||
@NonNull String name;
|
||||
|
||||
Author(String name) {
|
||||
if (name.isEmpty()) {
|
||||
throw new IllegalArgumentException("Author cannot be empty");
|
||||
}
|
||||
this.name = name.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.pillopl.library.catalogue;
|
||||
|
||||
import lombok.NonNull;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Value
|
||||
public class BookId {
|
||||
|
||||
@NonNull
|
||||
UUID bookId;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package io.pillopl.library.catalogue;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.NonNull;
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Value
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
class BookInstance {
|
||||
|
||||
@NonNull
|
||||
ISBN bookIsbn;
|
||||
@NonNull
|
||||
BookId bookId;
|
||||
@NonNull
|
||||
BookType bookType;
|
||||
|
||||
static BookInstance instanceOf(Book book, BookType bookType) {
|
||||
return new BookInstance(book.getBookIsbn(), new BookId(UUID.randomUUID()), bookType);
|
||||
|
||||
}
|
||||
}
|
||||