├── src ├── main │ ├── java │ │ └── example │ │ │ ├── catalog │ │ │ ├── domain │ │ │ │ ├── package-info.java │ │ │ │ ├── CatalogRepository.java │ │ │ │ └── CatalogBook.java │ │ │ ├── ui │ │ │ │ ├── package-info.java │ │ │ │ └── CatalogController.java │ │ │ ├── application │ │ │ │ ├── package-info.java │ │ │ │ ├── BookDto.java │ │ │ │ └── CatalogManagement.java │ │ │ └── BookAddedToCatalog.java │ │ │ ├── borrow │ │ │ ├── domain │ │ │ │ ├── package-info.java │ │ │ │ ├── HoldRepository.java │ │ │ │ ├── Patron.java │ │ │ │ ├── BookRepository.java │ │ │ │ ├── Book.java │ │ │ │ └── Hold.java │ │ │ ├── application │ │ │ │ ├── package-info.java │ │ │ │ ├── HoldInformation.java │ │ │ │ └── CirculationDesk.java │ │ │ └── infrastructure │ │ │ │ ├── package-info.java │ │ │ │ └── CirculationDeskController.java │ │ │ ├── useraccount │ │ │ ├── web │ │ │ │ ├── package-info.java │ │ │ │ ├── Authenticated.java │ │ │ │ └── AuthenticatedUserArgumentResolver.java │ │ │ ├── package-info.java │ │ │ ├── UserAccount.java │ │ │ └── KeycloakJwtAuthenticationConverter.java │ │ │ ├── LibraryApplication.java │ │ │ └── LibraryWebSecurityConfiguration.java │ └── resources │ │ └── application.yaml └── test │ ├── resources │ ├── catalog_books.sql │ └── borrow.sql │ └── java │ └── example │ ├── borrow │ ├── BorrowJMoleculesTests.java │ ├── CirculationDeskIT.java │ ├── CirculationDeskControllerIT.java │ └── CirculationDeskTest.java │ ├── catalog │ ├── CatalogJMoleculesTests.java │ ├── CatalogIntegrationTests.java │ └── CatalogControllerIT.java │ └── SpringModulithTests.java ├── .github └── dependabot.yml ├── docker-compose.yml ├── LICENSE ├── .gitignore ├── pom.xml ├── README.md └── keycloak-realm └── library-realm.json /src/main/java/example/catalog/domain/package-info.java: -------------------------------------------------------------------------------- 1 | @DomainLayer 2 | package example.catalog.domain; 3 | 4 | import org.jmolecules.architecture.layered.DomainLayer; -------------------------------------------------------------------------------- /src/main/java/example/catalog/ui/package-info.java: -------------------------------------------------------------------------------- 1 | @InterfaceLayer 2 | package example.catalog.ui; 3 | 4 | import org.jmolecules.architecture.layered.InterfaceLayer; -------------------------------------------------------------------------------- /src/main/java/example/borrow/domain/package-info.java: -------------------------------------------------------------------------------- 1 | @DomainRing 2 | package example.borrow.domain; 3 | 4 | import org.jmolecules.architecture.onion.simplified.DomainRing; -------------------------------------------------------------------------------- /src/main/java/example/useraccount/web/package-info.java: -------------------------------------------------------------------------------- 1 | @NamedInterface(value = "web") 2 | package example.useraccount.web; 3 | 4 | import org.springframework.modulith.NamedInterface; -------------------------------------------------------------------------------- /src/main/java/example/catalog/application/package-info.java: -------------------------------------------------------------------------------- 1 | @ApplicationLayer 2 | package example.catalog.application; 3 | 4 | import org.jmolecules.architecture.layered.ApplicationLayer; -------------------------------------------------------------------------------- /src/main/java/example/borrow/application/package-info.java: -------------------------------------------------------------------------------- 1 | @ApplicationRing 2 | package example.borrow.application; 3 | 4 | import org.jmolecules.architecture.onion.simplified.ApplicationRing; -------------------------------------------------------------------------------- /src/main/java/example/useraccount/package-info.java: -------------------------------------------------------------------------------- 1 | @ApplicationModule(displayName = "User Account") 2 | package example.useraccount; 3 | 4 | import org.springframework.modulith.ApplicationModule; -------------------------------------------------------------------------------- /src/main/java/example/borrow/infrastructure/package-info.java: -------------------------------------------------------------------------------- 1 | @InfrastructureRing 2 | package example.borrow.infrastructure; 3 | 4 | import org.jmolecules.architecture.onion.simplified.InfrastructureRing; -------------------------------------------------------------------------------- /src/main/java/example/borrow/domain/HoldRepository.java: -------------------------------------------------------------------------------- 1 | package example.borrow.domain; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | 5 | public interface HoldRepository extends CrudRepository { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/example/catalog/domain/CatalogRepository.java: -------------------------------------------------------------------------------- 1 | package example.catalog.domain; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | public interface CatalogRepository extends JpaRepository { 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/example/catalog/BookAddedToCatalog.java: -------------------------------------------------------------------------------- 1 | package example.catalog; 2 | 3 | import org.jmolecules.event.annotation.DomainEvent; 4 | 5 | @DomainEvent 6 | public record BookAddedToCatalog(String title, String inventoryNumber, 7 | String isbn, String author) { 8 | } 9 | -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | spring: 2 | jpa: 3 | open-in-view: false 4 | show-sql: true 5 | defer-datasource-initialization: true 6 | 7 | security: 8 | oauth2: 9 | resourceserver: 10 | jwt: 11 | issuer-uri: http://localhost:8083/realms/library 12 | -------------------------------------------------------------------------------- /src/test/resources/catalog_books.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO catalog_books (id, title, barcode, isbn, author) 2 | VALUES 3 | (999, 'Sapiens', '13268510', '9780062316097', 'Yuval Noah Harari'), 4 | (998, 'To Kill a Mockingbird', '49031878', '9780446310789', 'Harper Lee'), 5 | (997, '1984', '37040952', '9780451520500', 'George Orwell'); 6 | -------------------------------------------------------------------------------- /src/main/java/example/useraccount/UserAccount.java: -------------------------------------------------------------------------------- 1 | package example.useraccount; 2 | 3 | import java.util.List; 4 | 5 | /** 6 | * A model to represent a user account. This is not promoted to Aggregate as there is no need yet. 7 | */ 8 | public record UserAccount(String firstName, String lastName, String email, List roles) { 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/example/useraccount/web/Authenticated.java: -------------------------------------------------------------------------------- 1 | package example.useraccount.web; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | @Target({ ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER }) 9 | @Retention(RetentionPolicy.RUNTIME) 10 | public @interface Authenticated { 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/example/LibraryApplication.java: -------------------------------------------------------------------------------- 1 | package example; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.modulith.Modulithic; 6 | 7 | @Modulithic( 8 | sharedModules = {"useraccount"} 9 | ) 10 | @SpringBootApplication 11 | public class LibraryApplication { 12 | 13 | public static void main(String[] args) { 14 | SpringApplication.run(LibraryApplication.class, args); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/example/catalog/application/BookDto.java: -------------------------------------------------------------------------------- 1 | package example.catalog.application; 2 | 3 | import example.catalog.domain.CatalogBook; 4 | 5 | public record BookDto(Long id, String title, CatalogBook.Barcode catalogNumber, 6 | String isbn, CatalogBook.Author author) { 7 | 8 | public static BookDto from(CatalogBook book) { 9 | return new BookDto( 10 | book.getId(), 11 | book.getTitle(), 12 | book.getCatalogNumber(), 13 | book.getIsbn(), 14 | book.getAuthor() 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/example/borrow/BorrowJMoleculesTests.java: -------------------------------------------------------------------------------- 1 | package example.borrow; 2 | 3 | import com.tngtech.archunit.junit.AnalyzeClasses; 4 | import com.tngtech.archunit.junit.ArchTest; 5 | import com.tngtech.archunit.lang.ArchRule; 6 | 7 | import org.jmolecules.archunit.JMoleculesArchitectureRules; 8 | import org.jmolecules.archunit.JMoleculesDddRules; 9 | 10 | @SuppressWarnings("unused") 11 | @AnalyzeClasses(packages = "example.borrow") 12 | public class BorrowJMoleculesTests { 13 | 14 | @ArchTest 15 | ArchRule dddRules = JMoleculesDddRules.all(); 16 | 17 | @ArchTest 18 | ArchRule hexagonal = JMoleculesArchitectureRules.ensureHexagonal(); 19 | } 20 | -------------------------------------------------------------------------------- /src/test/java/example/catalog/CatalogJMoleculesTests.java: -------------------------------------------------------------------------------- 1 | package example.catalog; 2 | 3 | import com.tngtech.archunit.junit.AnalyzeClasses; 4 | import com.tngtech.archunit.junit.ArchTest; 5 | import com.tngtech.archunit.lang.ArchRule; 6 | 7 | import org.jmolecules.archunit.JMoleculesArchitectureRules; 8 | import org.jmolecules.archunit.JMoleculesDddRules; 9 | 10 | @SuppressWarnings("unused") 11 | @AnalyzeClasses(packages = "example.catalog") 12 | public class CatalogJMoleculesTests { 13 | 14 | @ArchTest 15 | ArchRule dddRules = JMoleculesDddRules.all(); 16 | 17 | @ArchTest 18 | ArchRule layering = JMoleculesArchitectureRules.ensureLayering(); 19 | } 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "maven" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | groups: 13 | dev-deps: 14 | dependency-type: "development" 15 | prod-deps: 16 | dependency-type: "production" 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | name: spring-modulith-with-ddd 3 | services: 4 | keycloak: 5 | image: quay.io/keycloak/keycloak:latest 6 | command: ['start-dev', '--import-realm'] 7 | volumes: 8 | - ./keycloak-realm:/opt/keycloak/data/import 9 | container_name: keycloak 10 | hostname: keycloak 11 | environment: 12 | - KEYCLOAK_ADMIN=admin 13 | - KEYCLOAK_ADMIN_PASSWORD=admin 14 | ports: 15 | - "8083:8080" 16 | 17 | library: 18 | image: "spring-modulith-with-ddd:0.0.1-SNAPSHOT" 19 | ports: 20 | - "8080:8080" 21 | environment: 22 | - SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK-SET-URI=http://keycloak:8080/realms/library/protocol/openid-connect/certs 23 | -------------------------------------------------------------------------------- /src/main/java/example/borrow/domain/Patron.java: -------------------------------------------------------------------------------- 1 | package example.borrow.domain; 2 | 3 | import jakarta.persistence.EnumType; 4 | import jakarta.persistence.Enumerated; 5 | import lombok.Getter; 6 | 7 | @Getter 8 | public class Patron { 9 | 10 | private final PatronId id; 11 | 12 | @Enumerated(EnumType.STRING) 13 | private Membership status; 14 | 15 | private Patron(String email) { 16 | this.id = new PatronId(email); 17 | } 18 | 19 | public static Patron of(String email) { 20 | return new Patron(email); 21 | } 22 | 23 | public void deactivate() { 24 | this.status = Membership.INACTIVE; 25 | } 26 | 27 | public record PatronId(String email) { 28 | } 29 | 30 | public enum Membership { 31 | ACTIVE, INACTIVE 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/resources/borrow.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO borrow_books (id, version, title, barcode, isbn, status) 2 | VALUES ('018dc771-7b96-776b-980d-caf7c6b2c00b', 0, 'Sapiens', '13268510', '9780062316097', 'AVAILABLE'), 3 | ('018dc771-6e03-7f3b-adc1-0b9f9810bde4', 0, 'Moby-Dick', '64321704', '9780763630188', 'AVAILABLE'), 4 | ('018dc771-97e4-7e1e-921f-50d3397d6b32', 0, 'To Kill a Mockingbird', '49031878', '9780446310789', 'ON_HOLD'), 5 | ('018dc771-bd5f-71c5-b481-e9b9e8268c6c', 0, '1984', '37040952', '9780451520500', 'ISSUED'); 6 | 7 | INSERT INTO borrow_holds (id, version, book_barcode, patron_id, date_of_hold, status) 8 | VALUES ('018dc74a-4830-75cf-a194-5e9815727b02', 0, '49031878', 'john.wick@continental.com', '2023-03-11', 'HOLDING'), 9 | ('018dc74a-8b3d-732e-806f-d210f079c0cc', 0, '37040952', 'winston@continental.com', '2023-03-24', 'ACTIVE'); 10 | -------------------------------------------------------------------------------- /src/main/java/example/borrow/domain/BookRepository.java: -------------------------------------------------------------------------------- 1 | package example.borrow.domain; 2 | 3 | import org.jmolecules.architecture.hexagonal.SecondaryPort; 4 | import org.jmolecules.ddd.annotation.Repository; 5 | import org.springframework.data.jpa.repository.Query; 6 | import org.springframework.data.repository.CrudRepository; 7 | 8 | import java.util.Optional; 9 | 10 | @Repository 11 | @SecondaryPort 12 | public interface BookRepository extends CrudRepository { 13 | 14 | @Query(""" 15 | SELECT b FROM Book b WHERE b.inventoryNumber = :inventoryNumber AND b.status = 'AVAILABLE' 16 | """) 17 | Optional findAvailableBook(Book.Barcode inventoryNumber); 18 | 19 | @Query(""" 20 | SELECT b FROM Book b WHERE b.inventoryNumber = :inventoryNumber AND b.status = 'ON_HOLD' 21 | """) 22 | Optional findOnHoldBook(Book.Barcode inventoryNumber); 23 | 24 | Optional findByInventoryNumber(Book.Barcode inventoryNumber); 25 | 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Abhinav Sonkar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/java/example/LibraryWebSecurityConfiguration.java: -------------------------------------------------------------------------------- 1 | package example; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; 6 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 7 | import org.springframework.security.web.SecurityFilterChain; 8 | 9 | import example.useraccount.KeycloakJwtAuthenticationConverter; 10 | 11 | @Configuration 12 | @EnableMethodSecurity 13 | public class LibraryWebSecurityConfiguration { 14 | 15 | @Bean 16 | public SecurityFilterChain filterChain(HttpSecurity security) { 17 | 18 | return security 19 | .authorizeHttpRequests(http -> http 20 | .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() 21 | .anyRequest().authenticated()) 22 | .oauth2ResourceServer(oauth2 -> 23 | oauth2.jwt(jwtConfigurer -> 24 | jwtConfigurer.jwtAuthenticationConverter(new KeycloakJwtAuthenticationConverter()) 25 | ) 26 | ).build(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/test/java/example/SpringModulithTests.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package example; 17 | 18 | import org.junit.jupiter.api.Test; 19 | import org.springframework.modulith.core.ApplicationModules; 20 | import org.springframework.modulith.docs.Documenter; 21 | 22 | class SpringModulithTests { 23 | 24 | ApplicationModules modules = ApplicationModules.of(LibraryApplication.class); 25 | 26 | @Test 27 | void verifyPackageConformity() { 28 | modules.verify(); 29 | } 30 | 31 | @Test 32 | void createModulithsDocumentation() { 33 | new Documenter(modules).writeDocumentation(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/example/borrow/application/HoldInformation.java: -------------------------------------------------------------------------------- 1 | package example.borrow.application; 2 | 3 | import java.time.LocalDate; 4 | 5 | import example.borrow.domain.Hold; 6 | import lombok.Getter; 7 | 8 | @Getter 9 | public class HoldInformation { 10 | 11 | private final String id; 12 | private final String bookBarcode; 13 | private final String patronId; 14 | private final LocalDate dateOfHold; 15 | private final LocalDate dateOfCheckout; 16 | private final Hold.HoldStatus holdStatus; 17 | 18 | private HoldInformation(String id, String bookBarcode, String patronId, LocalDate dateOfHold, LocalDate dateOfCheckout, Hold.HoldStatus holdStatus) { 19 | this.id = id; 20 | this.bookBarcode = bookBarcode; 21 | this.patronId = patronId; 22 | this.dateOfHold = dateOfHold; 23 | this.dateOfCheckout = dateOfCheckout; 24 | this.holdStatus = holdStatus; 25 | } 26 | 27 | public static HoldInformation from(Hold hold) { 28 | return new HoldInformation( 29 | hold.getId().id().toString(), 30 | hold.getOnBook().barcode(), 31 | hold.getHeldBy().email(), 32 | hold.getDateOfHold(), hold.getDateOfCheckout(), hold.getStatus()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/example/catalog/application/CatalogManagement.java: -------------------------------------------------------------------------------- 1 | package example.catalog.application; 2 | 3 | import org.springframework.stereotype.Service; 4 | import org.springframework.transaction.annotation.Transactional; 5 | 6 | import java.util.List; 7 | import java.util.Optional; 8 | 9 | import example.catalog.domain.CatalogBook; 10 | import example.catalog.domain.CatalogRepository; 11 | import lombok.RequiredArgsConstructor; 12 | import lombok.extern.slf4j.Slf4j; 13 | 14 | @Slf4j 15 | @Transactional 16 | @Service 17 | @RequiredArgsConstructor 18 | public class CatalogManagement { 19 | 20 | private final CatalogRepository catalogRepository; 21 | 22 | /** 23 | * Add a new book to the library. 24 | */ 25 | public BookDto addToCatalog(String title, CatalogBook.Barcode catalogNumber, String isbn, String authorName) { 26 | var book = new CatalogBook(title, catalogNumber, isbn, new CatalogBook.Author(authorName)); 27 | return BookDto.from(catalogRepository.save(book)); 28 | } 29 | 30 | @Transactional(readOnly = true) 31 | public Optional locate(Long id) { 32 | return catalogRepository.findById(id) 33 | .map(BookDto::from); 34 | } 35 | 36 | @Transactional(readOnly = true) 37 | public List fetchBooks() { 38 | return catalogRepository.findAll() 39 | .stream() 40 | .map(BookDto::from) 41 | .toList(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/example/catalog/ui/CatalogController.java: -------------------------------------------------------------------------------- 1 | package example.catalog.ui; 2 | 3 | import example.catalog.domain.CatalogBook.Barcode; 4 | 5 | import org.springframework.http.ResponseEntity; 6 | import org.springframework.security.access.prepost.PreAuthorize; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.PathVariable; 9 | import org.springframework.web.bind.annotation.PostMapping; 10 | import org.springframework.web.bind.annotation.RequestBody; 11 | import org.springframework.web.bind.annotation.RestController; 12 | 13 | import java.util.List; 14 | 15 | import example.catalog.application.BookDto; 16 | import example.catalog.application.CatalogManagement; 17 | import lombok.RequiredArgsConstructor; 18 | 19 | @RestController 20 | @RequiredArgsConstructor 21 | class CatalogController { 22 | 23 | private final CatalogManagement books; 24 | 25 | @PreAuthorize("hasRole('STAFF')") 26 | @PostMapping("/catalog/books") 27 | ResponseEntity addBookToInventory(@RequestBody AddBookRequest request) { 28 | var bookDto = books.addToCatalog(request.title(), new Barcode(request.catalogNumber()), request.isbn(), request.author()); 29 | return ResponseEntity.ok(bookDto); 30 | } 31 | 32 | @GetMapping("/catalog/books/{id}") 33 | ResponseEntity viewSingleBook(@PathVariable("id") Long id) { 34 | return books.locate(id) 35 | .map(ResponseEntity::ok) 36 | .orElse(ResponseEntity.notFound().build()); 37 | } 38 | 39 | @GetMapping("/catalog/books") 40 | ResponseEntity> viewBooks() { 41 | return ResponseEntity.ok(books.fetchBooks()); 42 | } 43 | 44 | record AddBookRequest(String title, String catalogNumber, 45 | String isbn, String author) { 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/example/catalog/domain/CatalogBook.java: -------------------------------------------------------------------------------- 1 | package example.catalog.domain; 2 | 3 | import org.jmolecules.ddd.annotation.AggregateRoot; 4 | import org.jmolecules.ddd.annotation.Identity; 5 | import org.springframework.data.domain.AbstractAggregateRoot; 6 | 7 | import example.catalog.BookAddedToCatalog; 8 | import jakarta.persistence.AttributeOverride; 9 | import jakarta.persistence.Column; 10 | import jakarta.persistence.Embedded; 11 | import jakarta.persistence.Entity; 12 | import jakarta.persistence.GeneratedValue; 13 | import jakarta.persistence.GenerationType; 14 | import jakarta.persistence.Id; 15 | import jakarta.persistence.Table; 16 | import jakarta.persistence.UniqueConstraint; 17 | import jakarta.persistence.Version; 18 | import lombok.Getter; 19 | import lombok.NoArgsConstructor; 20 | 21 | @SuppressWarnings("JpaDataSourceORMInspection") 22 | @AggregateRoot 23 | @Entity 24 | @Getter 25 | @NoArgsConstructor 26 | @Table(name = "catalog_books", uniqueConstraints = @UniqueConstraint(columnNames = {"barcode"})) 27 | public class CatalogBook extends AbstractAggregateRoot { 28 | 29 | @Identity 30 | @Id 31 | @GeneratedValue(strategy = GenerationType.IDENTITY) 32 | private Long id; 33 | 34 | private String title; 35 | 36 | @Embedded 37 | private Barcode catalogNumber; 38 | 39 | private String isbn; 40 | 41 | @Embedded 42 | @AttributeOverride(name = "name", column = @Column(name = "author")) 43 | private Author author; 44 | 45 | @SuppressWarnings("unused") 46 | @Version 47 | private Long version; 48 | 49 | public CatalogBook(String title, Barcode catalogNumber, String isbn, Author author) { 50 | this.title = title; 51 | this.catalogNumber = catalogNumber; 52 | this.isbn = isbn; 53 | this.author = author; 54 | this.registerEvent(new BookAddedToCatalog(title, catalogNumber.barcode(), isbn, author.name())); 55 | } 56 | 57 | public record Barcode(String barcode) { 58 | } 59 | 60 | public record Author(String name) { 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/example/useraccount/KeycloakJwtAuthenticationConverter.java: -------------------------------------------------------------------------------- 1 | package example.useraccount; 2 | 3 | import org.springframework.core.convert.converter.Converter; 4 | import org.springframework.security.authentication.AbstractAuthenticationToken; 5 | import org.springframework.security.core.GrantedAuthority; 6 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 7 | import org.springframework.security.oauth2.jwt.Jwt; 8 | import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; 9 | import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; 10 | 11 | import java.util.Collection; 12 | import java.util.List; 13 | import java.util.Map; 14 | import java.util.Optional; 15 | 16 | public class KeycloakJwtAuthenticationConverter implements Converter { 17 | private final Converter> delegate = new JwtGrantedAuthoritiesConverter(); 18 | 19 | @SuppressWarnings("NullableProblems") 20 | @Override 21 | public AbstractAuthenticationToken convert(Jwt jwt) { 22 | List extractedAuthorities = extractRoles(jwt); 23 | Collection authorities = delegate.convert(jwt); 24 | if (authorities != null) { 25 | authorities.addAll(extractedAuthorities); 26 | } 27 | return new JwtAuthenticationToken(jwt, authorities); 28 | } 29 | 30 | private List extractRoles(Jwt jwt) { 31 | //noinspection unchecked 32 | return Optional.of(jwt) 33 | .map(Jwt::getClaims) 34 | .map(claims -> (Map) claims.get("realm_access")) 35 | .map(it -> (List) it.get("roles")) 36 | .map(roles -> roles.stream() 37 | .filter(role -> role.startsWith("ROLE_")) 38 | .map(role -> (GrantedAuthority) new SimpleGrantedAuthority(role)) 39 | .toList()) 40 | .orElse(List.of()); 41 | } 42 | } -------------------------------------------------------------------------------- /src/test/java/example/catalog/CatalogIntegrationTests.java: -------------------------------------------------------------------------------- 1 | package example.catalog; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.modulith.test.ApplicationModuleTest; 6 | import org.springframework.modulith.test.Scenario; 7 | import org.springframework.test.context.DynamicPropertyRegistry; 8 | import org.springframework.test.context.DynamicPropertySource; 9 | import org.springframework.transaction.annotation.Transactional; 10 | 11 | import example.catalog.domain.CatalogBook.Barcode; 12 | import example.catalog.application.CatalogManagement; 13 | import example.catalog.domain.CatalogRepository; 14 | 15 | import static org.assertj.core.api.Assertions.assertThat; 16 | 17 | @Transactional 18 | @ApplicationModuleTest 19 | class CatalogIntegrationTests { 20 | 21 | @DynamicPropertySource 22 | static void initializeData(DynamicPropertyRegistry registry) { 23 | registry.add("spring.sql.init.data-locations", () -> "classpath:catalog_books.sql"); 24 | } 25 | 26 | @Autowired 27 | CatalogManagement books; 28 | 29 | @Autowired 30 | CatalogRepository repository; 31 | 32 | @Test 33 | void shouldAddBookToInventory(Scenario scenario) { 34 | scenario.stimulate(() -> books.addToCatalog("A title", new Barcode("999"), "654", "An author")) 35 | .andCleanup(bookDto -> repository.deleteById(bookDto.id())) 36 | .andWaitForEventOfType(BookAddedToCatalog.class) 37 | .toArriveAndVerify((event, dto) -> { 38 | assertThat(event.title()).isEqualTo("A title"); 39 | assertThat(event.inventoryNumber()).isEqualTo("999"); 40 | assertThat(event.isbn()).isEqualTo("654"); 41 | assertThat(event.author()).isEqualTo("An author"); 42 | assertThat(dto.id()).isNotNull(); 43 | }); 44 | } 45 | 46 | @Test 47 | void shouldListBooks() { 48 | var issuedBooks = books.fetchBooks(); 49 | assertThat(issuedBooks).hasSizeBetween(3, 4); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/example/borrow/infrastructure/CirculationDeskController.java: -------------------------------------------------------------------------------- 1 | package example.borrow.infrastructure; 2 | 3 | import org.springframework.http.ResponseEntity; 4 | import org.springframework.web.bind.annotation.GetMapping; 5 | import org.springframework.web.bind.annotation.PathVariable; 6 | import org.springframework.web.bind.annotation.PostMapping; 7 | import org.springframework.web.bind.annotation.RequestBody; 8 | import org.springframework.web.bind.annotation.RestController; 9 | 10 | import java.time.LocalDate; 11 | import java.util.UUID; 12 | 13 | import example.borrow.application.CirculationDesk; 14 | import example.borrow.application.HoldInformation; 15 | import example.borrow.domain.Book; 16 | import example.borrow.domain.Hold; 17 | import example.borrow.domain.Patron.PatronId; 18 | import example.useraccount.UserAccount; 19 | import example.useraccount.web.Authenticated; 20 | import lombok.RequiredArgsConstructor; 21 | 22 | @RestController 23 | @RequiredArgsConstructor 24 | public class CirculationDeskController { 25 | 26 | private final CirculationDesk circulationDesk; 27 | 28 | @PostMapping("/borrow/holds") 29 | ResponseEntity holdBook(@RequestBody HoldRequest request, @Authenticated UserAccount userAccount) { 30 | var command = new Hold.PlaceHold(new Book.Barcode(request.barcode()), LocalDate.now(), new PatronId(userAccount.email())); 31 | var holdDto = circulationDesk.placeHold(command); 32 | return ResponseEntity.ok(holdDto); 33 | } 34 | 35 | @PostMapping("/borrow/holds/{id}/checkout") 36 | ResponseEntity checkoutBook(@PathVariable("id") UUID holdId, @Authenticated UserAccount userAccount) { 37 | var command = new Hold.Checkout(new Hold.HoldId(holdId), LocalDate.now(), new PatronId(userAccount.email())); 38 | var hold = circulationDesk.checkout(command); 39 | return ResponseEntity.ok(hold); 40 | } 41 | 42 | @GetMapping("/borrow/holds/{id}") 43 | ResponseEntity viewSingleHold(@PathVariable("id") UUID holdId) { 44 | return circulationDesk.locate(holdId) 45 | .map(ResponseEntity::ok) 46 | .orElse(ResponseEntity.notFound().build()); 47 | } 48 | 49 | record HoldRequest(String barcode) { 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/example/borrow/domain/Book.java: -------------------------------------------------------------------------------- 1 | package example.borrow.domain; 2 | 3 | import org.jmolecules.ddd.types.Identifier; 4 | import org.jmolecules.ddd.types.ValueObject; 5 | 6 | import java.util.UUID; 7 | 8 | import jakarta.persistence.Embedded; 9 | import jakarta.persistence.EmbeddedId; 10 | import jakarta.persistence.Entity; 11 | import jakarta.persistence.EnumType; 12 | import jakarta.persistence.Enumerated; 13 | import jakarta.persistence.Table; 14 | import jakarta.persistence.UniqueConstraint; 15 | import jakarta.persistence.Version; 16 | import lombok.Getter; 17 | import lombok.NoArgsConstructor; 18 | 19 | @SuppressWarnings("JpaDataSourceORMInspection") 20 | @Entity 21 | @NoArgsConstructor 22 | @Table(name = "borrow_books", uniqueConstraints = @UniqueConstraint(columnNames = "barcode")) 23 | @Getter 24 | public class Book { 25 | 26 | @EmbeddedId 27 | private BookId id; 28 | 29 | @Embedded 30 | private Barcode inventoryNumber; 31 | 32 | private String title; 33 | 34 | private String isbn; 35 | 36 | @Enumerated(EnumType.STRING) 37 | private BookStatus status; 38 | 39 | @SuppressWarnings("unused") 40 | @Version 41 | private Long version; 42 | 43 | private Book(AddBook addBook) { 44 | this.id = new BookId(UUID.randomUUID()); 45 | this.inventoryNumber = addBook.barcode(); 46 | this.title = addBook.title(); 47 | this.isbn = addBook.isbn(); 48 | this.status = BookStatus.AVAILABLE; 49 | } 50 | 51 | public static Book addBook(AddBook command) { 52 | return new Book(command); 53 | } 54 | 55 | public Book markOnHold() { 56 | this.status = BookStatus.ON_HOLD; 57 | return this; 58 | } 59 | 60 | public Book markCheckedOut() { 61 | this.status = BookStatus.ISSUED; 62 | return this; 63 | } 64 | 65 | public record BookId(UUID id) implements Identifier { 66 | } 67 | 68 | public record Barcode(String barcode) implements ValueObject { 69 | 70 | public static Barcode of(String barcode) { 71 | return new Barcode(barcode); 72 | } 73 | } 74 | 75 | public enum BookStatus implements ValueObject { 76 | AVAILABLE, ON_HOLD, ISSUED 77 | } 78 | 79 | /** 80 | * Command to add a new book 81 | */ 82 | public record AddBook(Barcode barcode, String title, String isbn) { 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/test/java/example/borrow/CirculationDeskIT.java: -------------------------------------------------------------------------------- 1 | package example.borrow; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.modulith.test.ApplicationModuleTest; 7 | import org.springframework.modulith.test.Scenario; 8 | import org.springframework.test.context.DynamicPropertyRegistry; 9 | import org.springframework.test.context.DynamicPropertySource; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | import java.time.Duration; 13 | import java.time.LocalDate; 14 | import java.util.UUID; 15 | 16 | import example.borrow.application.CirculationDesk; 17 | import example.borrow.domain.Book; 18 | import example.borrow.domain.BookRepository; 19 | import example.borrow.domain.Hold; 20 | import example.borrow.domain.Hold.BookPlacedOnHold; 21 | import example.borrow.domain.HoldRepository; 22 | import example.borrow.domain.Patron.PatronId; 23 | 24 | import static org.assertj.core.api.Assertions.assertThat; 25 | 26 | @Transactional 27 | @ApplicationModuleTest 28 | class CirculationDeskIT { 29 | 30 | @DynamicPropertySource 31 | static void initializeData(DynamicPropertyRegistry registry) { 32 | registry.add("spring.sql.init.data-locations", () -> "classpath:borrow.sql"); 33 | } 34 | 35 | @Autowired 36 | BookRepository books; 37 | 38 | @Autowired 39 | HoldRepository holds; 40 | 41 | CirculationDesk circulationDesk; 42 | 43 | @BeforeEach 44 | void setUp() { 45 | circulationDesk = new CirculationDesk(books, holds); 46 | } 47 | 48 | @Test 49 | void patronCanPlaceHold(Scenario scenario) { 50 | var command = new Hold.PlaceHold(new Book.Barcode("13268510"), LocalDate.now(), new PatronId("john.wick@continental.com")); 51 | scenario.stimulate(() -> circulationDesk.placeHold(command)) 52 | .andWaitForEventOfType(BookPlacedOnHold.class) 53 | .toArriveAndVerify((event, _) -> assertThat(event.inventoryNumber()).isEqualTo("13268510")); 54 | } 55 | 56 | @Test 57 | void bookStatusIsUpdatedWhenPlacedOnHold(Scenario scenario) { 58 | var event = new BookPlacedOnHold(UUID.randomUUID(), "64321704", LocalDate.now()); 59 | scenario.publish(() -> event) 60 | .customize(it -> it.atMost(Duration.ofMillis(200))) 61 | .andWaitForStateChange(() -> books.findByInventoryNumber(Book.Barcode.of("64321704"))) 62 | .andVerify(book -> { 63 | assertThat(book).isNotEmpty(); 64 | assertThat(book.get().getInventoryNumber().barcode()).isEqualTo("64321704"); 65 | assertThat(book.get().getStatus()).isEqualTo(Book.BookStatus.ON_HOLD); 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/example/borrow/application/CirculationDesk.java: -------------------------------------------------------------------------------- 1 | package example.borrow.application; 2 | 3 | import org.springframework.modulith.events.ApplicationModuleListener; 4 | import org.springframework.stereotype.Service; 5 | import org.springframework.transaction.annotation.Transactional; 6 | 7 | import java.util.Optional; 8 | import java.util.UUID; 9 | 10 | import example.borrow.domain.Book; 11 | import example.borrow.domain.BookRepository; 12 | import example.borrow.domain.Hold; 13 | import example.borrow.domain.HoldRepository; 14 | import example.catalog.BookAddedToCatalog; 15 | 16 | @Service 17 | @Transactional 18 | public class CirculationDesk { 19 | 20 | private final BookRepository books; 21 | private final HoldRepository holds; 22 | 23 | public CirculationDesk(BookRepository books, HoldRepository holds) { 24 | this.books = books; 25 | this.holds = holds; 26 | } 27 | 28 | public HoldInformation placeHold(Hold.PlaceHold command) { 29 | books.findAvailableBook(command.inventoryNumber()) 30 | .orElseThrow(() -> new IllegalArgumentException("Book not found")); 31 | 32 | var hold = Hold.placeHold(command) 33 | .then(holds::save); 34 | 35 | return HoldInformation.from(hold); 36 | } 37 | 38 | public Optional locate(UUID holdId) { 39 | return holds.findById(new Hold.HoldId(holdId)) 40 | .map(HoldInformation::from); 41 | } 42 | 43 | public HoldInformation checkout(Hold.Checkout command) { 44 | var hold = holds.findById(command.holdId()) 45 | .orElseThrow(() -> new IllegalArgumentException("Hold not found!")); 46 | 47 | if (!hold.isHeldBy(command.patronId())) { 48 | throw new IllegalArgumentException("Hold belongs to a different patron"); 49 | } 50 | 51 | return HoldInformation.from( 52 | hold.checkout(command) 53 | .then(holds::save) 54 | ); 55 | } 56 | 57 | @ApplicationModuleListener 58 | public void handle(Hold.BookPlacedOnHold event) { 59 | books.findAvailableBook(new Book.Barcode(event.inventoryNumber())) 60 | .map(Book::markOnHold) 61 | .map(books::save) 62 | .orElseThrow(() -> new IllegalArgumentException("Duplicate hold?")); 63 | } 64 | 65 | @ApplicationModuleListener 66 | public void handle(Hold.BookCheckedOut event) { 67 | books.findOnHoldBook(new Book.Barcode(event.inventoryNumber())) 68 | .map(Book::markCheckedOut) 69 | .map(books::save) 70 | .orElseThrow(() -> new IllegalArgumentException("Book not on hold?")); 71 | } 72 | 73 | @ApplicationModuleListener 74 | public void handle(BookAddedToCatalog event) { 75 | var command = new Book.AddBook(new Book.Barcode(event.inventoryNumber()), event.title(), event.isbn()); 76 | books.save(Book.addBook(command)); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | ### Intellij template 35 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 36 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 37 | 38 | # User-specific stuff 39 | .idea/**/workspace.xml 40 | .idea/**/tasks.xml 41 | .idea/**/usage.statistics.xml 42 | .idea/**/dictionaries 43 | .idea/**/shelf 44 | 45 | # AWS User-specific 46 | .idea/**/aws.xml 47 | 48 | # Generated files 49 | .idea/**/contentModel.xml 50 | 51 | # Sensitive or high-churn files 52 | .idea/**/dataSources/ 53 | .idea/**/dataSources.ids 54 | .idea/**/dataSources.local.xml 55 | .idea/**/sqlDataSources.xml 56 | .idea/**/dynamic.xml 57 | .idea/**/uiDesigner.xml 58 | .idea/**/dbnavigator.xml 59 | 60 | # Gradle 61 | .idea/**/gradle.xml 62 | .idea/**/libraries 63 | 64 | # Gradle and Maven with auto-import 65 | # When using Gradle or Maven with auto-import, you should exclude module files, 66 | # since they will be recreated, and may cause churn. Uncomment if using 67 | # auto-import. 68 | # .idea/artifacts 69 | # .idea/compiler.xml 70 | # .idea/jarRepositories.xml 71 | # .idea/modules.xml 72 | # .idea/*.iml 73 | # .idea/modules 74 | # *.iml 75 | # *.ipr 76 | 77 | # CMake 78 | cmake-build-*/ 79 | 80 | # Mongo Explorer plugin 81 | .idea/**/mongoSettings.xml 82 | 83 | # File-based project format 84 | *.iws 85 | 86 | # mpeltonen/sbt-idea plugin 87 | .idea_modules/ 88 | 89 | # JIRA plugin 90 | atlassian-ide-plugin.xml 91 | 92 | # Cursive Clojure plugin 93 | .idea/replstate.xml 94 | 95 | # SonarLint plugin 96 | .idea/sonarlint/ 97 | 98 | # Crashlytics plugin (for Android Studio and IntelliJ) 99 | com_crashlytics_export_strings.xml 100 | crashlytics.properties 101 | crashlytics-build.properties 102 | fabric.properties 103 | 104 | # Editor-based Rest Client 105 | .idea/httpRequests 106 | 107 | # Android studio 3.1+ serialized cache file 108 | .idea/caches/build_file_checksums.ser 109 | 110 | ### Java template 111 | # Compiled class file 112 | *.class 113 | 114 | # Log file 115 | *.log 116 | 117 | # BlueJ files 118 | *.ctxt 119 | 120 | # Mobile Tools for Java (J2ME) 121 | .mtj.tmp/ 122 | 123 | # Package Files # 124 | *.jar 125 | *.war 126 | *.nar 127 | *.ear 128 | *.zip 129 | *.tar.gz 130 | *.rar 131 | 132 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 133 | hs_err_pid* 134 | replay_pid* 135 | 136 | -------------------------------------------------------------------------------- /src/test/java/example/borrow/CirculationDeskControllerIT.java: -------------------------------------------------------------------------------- 1 | package example.borrow; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.http.MediaType; 7 | import org.springframework.modulith.test.ApplicationModuleTest; 8 | import org.springframework.test.context.DynamicPropertyRegistry; 9 | import org.springframework.test.context.DynamicPropertySource; 10 | import org.springframework.test.web.servlet.MockMvc; 11 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 12 | import org.springframework.web.context.WebApplicationContext; 13 | 14 | import static org.hamcrest.Matchers.equalTo; 15 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; 16 | import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; 17 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 18 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 19 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 20 | 21 | @ApplicationModuleTest 22 | class CirculationDeskControllerIT { 23 | 24 | @DynamicPropertySource 25 | static void initializeData(DynamicPropertyRegistry registry) { 26 | registry.add("spring.sql.init.data-locations", () -> "classpath:borrow.sql"); 27 | } 28 | 29 | @Autowired 30 | WebApplicationContext context; 31 | 32 | private MockMvc mockMvc; 33 | 34 | @BeforeEach 35 | void setUp() { 36 | this.mockMvc = MockMvcBuilders.webAppContextSetup(context) 37 | .apply(springSecurity()) 38 | .build(); 39 | } 40 | 41 | @Test 42 | void placeHoldRestCall() throws Exception { 43 | mockMvc.perform(post("/borrow/holds") 44 | .with(jwt().jwt(jwt -> jwt.claim("email", "john.wick@continental.com"))) 45 | .contentType(MediaType.APPLICATION_JSON) 46 | .content(""" 47 | { 48 | "barcode": "64321704" 49 | } 50 | """)) 51 | .andExpect(status().isOk()) 52 | .andExpect(jsonPath("$.id").exists()) 53 | .andExpect(jsonPath("$.bookBarcode", equalTo("64321704"))) 54 | .andExpect(jsonPath("$.patronId", equalTo("john.wick@continental.com"))) 55 | .andExpect(jsonPath("$.dateOfHold").exists()) 56 | .andExpect(jsonPath("$.dateOfCheckout").isEmpty()) 57 | .andExpect(jsonPath("$.holdStatus", equalTo("HOLDING"))); 58 | } 59 | 60 | @Test 61 | void checkoutBookRestCall() throws Exception { 62 | mockMvc.perform(post("/borrow/holds/018dc74a-4830-75cf-a194-5e9815727b02/checkout") 63 | .with(jwt().jwt(jwt -> jwt.claim("email", "john.wick@continental.com")))) 64 | .andExpect(status().isOk()) 65 | .andExpect(jsonPath("$.id", equalTo("018dc74a-4830-75cf-a194-5e9815727b02"))) 66 | .andExpect(jsonPath("$.patronId", equalTo("john.wick@continental.com"))) 67 | .andExpect(jsonPath("$.dateOfCheckout").isNotEmpty()) 68 | .andExpect(jsonPath("$.holdStatus", equalTo("ACTIVE"))); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/java/example/borrow/domain/Hold.java: -------------------------------------------------------------------------------- 1 | package example.borrow.domain; 2 | 3 | import org.jmolecules.ddd.types.Identifier; 4 | import org.jmolecules.event.annotation.DomainEvent; 5 | import org.springframework.data.domain.AbstractAggregateRoot; 6 | 7 | import java.time.LocalDate; 8 | import java.util.UUID; 9 | import java.util.function.UnaryOperator; 10 | 11 | import example.borrow.domain.Patron.PatronId; 12 | import jakarta.persistence.AttributeOverride; 13 | import jakarta.persistence.Column; 14 | import jakarta.persistence.Embedded; 15 | import jakarta.persistence.EmbeddedId; 16 | import jakarta.persistence.Entity; 17 | import jakarta.persistence.EnumType; 18 | import jakarta.persistence.Enumerated; 19 | import jakarta.persistence.Table; 20 | import jakarta.persistence.Version; 21 | import lombok.Getter; 22 | import lombok.NoArgsConstructor; 23 | 24 | @SuppressWarnings("JpaDataSourceORMInspection") 25 | @Entity 26 | @NoArgsConstructor 27 | @Table(name = "borrow_holds") 28 | @Getter 29 | public class Hold extends AbstractAggregateRoot { 30 | 31 | @EmbeddedId 32 | private HoldId id; 33 | 34 | @Embedded 35 | @AttributeOverride(name = "barcode", column = @Column(name = "book_barcode")) 36 | private Book.Barcode onBook; 37 | 38 | @Embedded 39 | @AttributeOverride(name = "email", column = @Column(name = "patron_id")) 40 | private PatronId heldBy; 41 | 42 | private LocalDate dateOfHold; 43 | 44 | private LocalDate dateOfCheckout; 45 | 46 | @Enumerated(EnumType.STRING) 47 | private HoldStatus status; 48 | 49 | @SuppressWarnings("unused") 50 | @Version 51 | private Long version; 52 | 53 | private Hold(PlaceHold placeHold) { 54 | this.id = new HoldId(UUID.randomUUID()); 55 | this.onBook = placeHold.inventoryNumber(); 56 | this.dateOfHold = placeHold.dateOfHold(); 57 | this.heldBy = placeHold.patronId(); 58 | this.status = HoldStatus.HOLDING; 59 | this.registerEvent(new BookPlacedOnHold(id.id(), onBook.barcode(), dateOfHold)); 60 | } 61 | 62 | public static Hold placeHold(PlaceHold command) { 63 | return new Hold(command); 64 | } 65 | 66 | public Hold checkout(Checkout command) { 67 | this.dateOfCheckout = command.dateOfCheckout(); 68 | this.status = HoldStatus.ACTIVE; 69 | this.registerEvent(new BookCheckedOut(id.id(), onBook.barcode(), dateOfCheckout)); 70 | return this; 71 | } 72 | 73 | public Hold then(UnaryOperator function) { 74 | return function.apply(this); 75 | } 76 | 77 | public boolean isHeldBy(PatronId patronId) { 78 | return this.heldBy.equals(patronId); 79 | } 80 | 81 | public record HoldId(UUID id) implements Identifier { 82 | } 83 | 84 | public enum HoldStatus { 85 | HOLDING, ACTIVE, COMPLETED 86 | } 87 | 88 | /// 89 | // Commands 90 | /// 91 | 92 | public record PlaceHold(Book.Barcode inventoryNumber, LocalDate dateOfHold, PatronId patronId) { 93 | } 94 | 95 | public record Checkout(HoldId holdId, LocalDate dateOfCheckout, PatronId patronId) { 96 | 97 | } 98 | 99 | /// 100 | // Events 101 | /// 102 | 103 | @DomainEvent 104 | public record BookCheckedOut(UUID holdId, 105 | String inventoryNumber, 106 | LocalDate dateOfCheckout) { 107 | } 108 | 109 | @DomainEvent 110 | public record BookPlacedOnHold(UUID holdId, 111 | String inventoryNumber, 112 | LocalDate dateOfHold) { 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/test/java/example/catalog/CatalogControllerIT.java: -------------------------------------------------------------------------------- 1 | package example.catalog; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.http.MediaType; 7 | import org.springframework.modulith.test.ApplicationModuleTest; 8 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 9 | import org.springframework.test.context.DynamicPropertyRegistry; 10 | import org.springframework.test.context.DynamicPropertySource; 11 | import org.springframework.test.web.servlet.MockMvc; 12 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 13 | import org.springframework.web.context.WebApplicationContext; 14 | 15 | import static org.hamcrest.Matchers.containsString; 16 | import static org.hamcrest.Matchers.equalTo; 17 | import static org.springframework.http.HttpHeaders.WWW_AUTHENTICATE; 18 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; 19 | import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; 20 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 21 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 22 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; 23 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 24 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 25 | 26 | @ApplicationModuleTest 27 | class CatalogControllerIT { 28 | 29 | @DynamicPropertySource 30 | static void initializeData(DynamicPropertyRegistry registry) { 31 | registry.add("spring.sql.init.data-locations", () -> "classpath:catalog_books.sql"); 32 | } 33 | 34 | @Autowired 35 | WebApplicationContext context; 36 | 37 | private MockMvc mockMvc; 38 | 39 | @BeforeEach 40 | void setUp() { 41 | this.mockMvc = MockMvcBuilders.webAppContextSetup(context) 42 | .apply(springSecurity()) 43 | .build(); 44 | } 45 | 46 | @Test 47 | void addBookToCatalogSucceedsWithStaff() throws Exception { 48 | mockMvc.perform(post("/catalog/books") 49 | .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_STAFF"))) 50 | .contentType(MediaType.APPLICATION_JSON) 51 | .content(""" 52 | { 53 | "title": "Sapiens", 54 | "catalogNumber": "12345", 55 | "isbn": "9780062316097", 56 | "author": "Yuval Noah Harari" 57 | } 58 | """)) 59 | .andExpect(status().isOk()) 60 | .andExpect(jsonPath("$.id").exists()) 61 | .andExpect(jsonPath("$.catalogNumber.barcode", equalTo("12345"))) 62 | .andExpect(jsonPath("$.isbn", equalTo("9780062316097"))) 63 | .andExpect(jsonPath("$.author.name", equalTo("Yuval Noah Harari"))); 64 | } 65 | 66 | @Test 67 | void addBookToCatalogFailsWithNonStaff() throws Exception { 68 | mockMvc.perform(post("/catalog/books") 69 | .with(jwt()) 70 | .contentType(MediaType.APPLICATION_JSON) 71 | .content(""" 72 | { 73 | "title": "Sapiens", 74 | "catalogNumber": "12345", 75 | "isbn": "9780062316097", 76 | "author": "Yuval Noah Harari" 77 | } 78 | """)) 79 | .andDo(print()) 80 | .andExpect(status().isForbidden()) 81 | .andExpect(header().string(WWW_AUTHENTICATE, containsString("Bearer error=\"insufficient_scope\""))); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/example/useraccount/web/AuthenticatedUserArgumentResolver.java: -------------------------------------------------------------------------------- 1 | package example.useraccount.web; 2 | 3 | import org.springframework.core.MethodParameter; 4 | import org.springframework.core.ResolvableType; 5 | import org.springframework.security.access.prepost.PreAuthorize; 6 | import org.springframework.security.core.GrantedAuthority; 7 | import org.springframework.security.core.context.SecurityContextHolder; 8 | import org.springframework.security.oauth2.jwt.Jwt; 9 | import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; 10 | import org.springframework.stereotype.Component; 11 | import org.springframework.web.bind.ServletRequestBindingException; 12 | import org.springframework.web.bind.support.WebDataBinderFactory; 13 | import org.springframework.web.context.request.NativeWebRequest; 14 | import org.springframework.web.method.support.HandlerMethodArgumentResolver; 15 | import org.springframework.web.method.support.ModelAndViewContainer; 16 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 17 | 18 | import java.util.List; 19 | import java.util.Optional; 20 | 21 | import example.useraccount.UserAccount; 22 | 23 | /** 24 | * {@link HandlerMethodArgumentResolver} to inject the {@link UserAccount} of the currently logged-in user 25 | * into REST controller method parameters annotated with {@link Authenticated}. The parameter can 26 | * also use {@link Optional} as wrapper for {@link UserAccount} to indicate that an anonymous invocation is 27 | * possible. 28 | */ 29 | @Component 30 | class AuthenticatedUserArgumentResolver implements HandlerMethodArgumentResolver, WebMvcConfigurer { 31 | 32 | private static final String USER_EXPECTED = "Expected to find a current user but none available! If the user does not necessarily have to be logged in, use Optional instead!"; 33 | private static final ResolvableType USER = ResolvableType.forClass(UserAccount.class); 34 | private static final ResolvableType OPTIONAL_OF_USER = 35 | ResolvableType.forClassWithGenerics(Optional.class, UserAccount.class); 36 | 37 | @SuppressWarnings("NullableProblems") 38 | @Override 39 | public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, 40 | NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { 41 | var user = getCurrentUser(); 42 | var parameterType = ResolvableType.forMethodParameter(parameter); 43 | 44 | if (OPTIONAL_OF_USER.isAssignableFrom(parameterType)) { 45 | return user; 46 | } 47 | 48 | return hasAuthorizationAnnotation(parameter) 49 | ? user.orElse(null) 50 | : user.orElseThrow(() -> new ServletRequestBindingException(USER_EXPECTED)); 51 | } 52 | 53 | private Optional getCurrentUser() { 54 | 55 | return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) 56 | .map(it -> (JwtAuthenticationToken) it) 57 | .map(token -> { 58 | Jwt jwt = (Jwt) token.getPrincipal(); 59 | var email = jwt.getClaimAsString("email"); 60 | var firstName = jwt.getClaimAsString("given_name"); 61 | var lastName = jwt.getClaimAsString("family_name"); 62 | var roles = token.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(); 63 | 64 | return new UserAccount(firstName, lastName, email, roles); 65 | }); 66 | } 67 | 68 | @Override 69 | public boolean supportsParameter(MethodParameter parameter) { 70 | 71 | if (!parameter.hasParameterAnnotation(Authenticated.class)) { 72 | return false; 73 | } 74 | 75 | var type = ResolvableType.forMethodParameter(parameter); 76 | 77 | return USER.isAssignableFrom(type) || OPTIONAL_OF_USER.isAssignableFrom(type); 78 | } 79 | 80 | @Override 81 | public void addArgumentResolvers(List resolvers) { 82 | resolvers.add(this); 83 | } 84 | 85 | private static boolean hasAuthorizationAnnotation(MethodParameter parameter) { 86 | return parameter.hasMethodAnnotation(PreAuthorize.class); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/test/java/example/borrow/CirculationDeskTest.java: -------------------------------------------------------------------------------- 1 | package example.borrow; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.extension.ExtendWith; 6 | import org.mockito.Mock; 7 | import org.mockito.junit.jupiter.MockitoExtension; 8 | 9 | import java.time.LocalDate; 10 | import java.util.Optional; 11 | 12 | import example.borrow.application.CirculationDesk; 13 | import example.borrow.domain.Book; 14 | import example.borrow.domain.BookRepository; 15 | import example.borrow.domain.Hold; 16 | import example.borrow.domain.Hold.BookCheckedOut; 17 | import example.borrow.domain.Hold.BookPlacedOnHold; 18 | import example.borrow.domain.HoldRepository; 19 | import example.borrow.domain.Patron.PatronId; 20 | 21 | import static example.borrow.domain.Book.BookStatus.ISSUED; 22 | import static example.borrow.domain.Book.BookStatus.ON_HOLD; 23 | import static org.assertj.core.api.Assertions.assertThat; 24 | import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; 25 | import static org.mockito.ArgumentMatchers.any; 26 | import static org.mockito.Mockito.when; 27 | 28 | @ExtendWith(MockitoExtension.class) 29 | class CirculationDeskTest { 30 | 31 | CirculationDesk circulationDesk; 32 | 33 | @Mock 34 | BookRepository bookRepository; 35 | 36 | @Mock 37 | HoldRepository holdRepository; 38 | 39 | @BeforeEach 40 | void setUp() { 41 | circulationDesk = new CirculationDesk(bookRepository, holdRepository); 42 | } 43 | 44 | @Test 45 | void patronCanPlaceHold() { 46 | var command = new Hold.PlaceHold(new Book.Barcode("12345"), LocalDate.now(), new PatronId("john.wick@continental.com")); 47 | var book = Book.addBook(new Book.AddBook(new Book.Barcode("12345"), "Test Book", "1234567890")); 48 | var hold = Hold.placeHold(command); 49 | when(bookRepository.findAvailableBook(any())).thenReturn(Optional.of(book)); 50 | when(holdRepository.save(any())).thenReturn(hold); 51 | 52 | var holdDto = circulationDesk.placeHold(command); 53 | 54 | assertThat(holdDto.getBookBarcode()).isEqualTo("12345"); 55 | assertThat(holdDto.getDateOfHold()).isNotNull(); 56 | } 57 | 58 | @Test 59 | void bookStatusUpdatedWhenPlacedOnHold() { 60 | var command = new Hold.PlaceHold(new Book.Barcode("12345"), LocalDate.now(), new PatronId("john.wick@continental.com")); 61 | var hold = Hold.placeHold(command); 62 | 63 | var book = Book.addBook(new Book.AddBook(new Book.Barcode("12345"), "Test Book", "1234567890")); 64 | when(bookRepository.findAvailableBook(any())).thenReturn(Optional.of(book)); 65 | when(bookRepository.save(any())).thenReturn(book); 66 | 67 | circulationDesk.handle(new BookPlacedOnHold(hold.getId().id(), hold.getOnBook().barcode(), hold.getDateOfHold())); 68 | 69 | assertThat(book.getStatus()).isEqualTo(ON_HOLD); 70 | } 71 | 72 | @Test 73 | void patronCanCheckoutBook() { 74 | var patronId = new PatronId("john.wick@continental.com"); 75 | var hold = Hold.placeHold(new Hold.PlaceHold(new Book.Barcode("12345"), LocalDate.now(), patronId)); 76 | var command = new Hold.Checkout(hold.getId(), LocalDate.now(), patronId); 77 | 78 | when(holdRepository.findById(any())).thenReturn(Optional.of(hold)); 79 | when(holdRepository.save(any())).thenReturn(hold); 80 | 81 | var holdInformation = circulationDesk.checkout(command); 82 | assertThat(holdInformation.getId()).isEqualTo(hold.getId().id().toString()); 83 | assertThat(holdInformation.getDateOfCheckout()).isNotNull(); 84 | } 85 | 86 | @Test 87 | void patronCannotCheckoutBookHeldBySomeoneElse() { 88 | var hold = Hold.placeHold(new Hold.PlaceHold(new Book.Barcode("12345"), LocalDate.now(), new PatronId("john.wick@continental.com"))); 89 | var command = new Hold.Checkout(hold.getId(), LocalDate.now(), new PatronId("winston@continental.com")); 90 | 91 | when(holdRepository.findById(any())).thenReturn(Optional.of(hold)); 92 | 93 | assertThatIllegalArgumentException() // 94 | .isThrownBy(() -> circulationDesk.checkout(command)) // 95 | .withMessage("Hold belongs to a different patron"); 96 | } 97 | 98 | @Test 99 | void bookStatusUpdatedWhenCheckoutBook() { 100 | // Arrange 101 | Book book = Book.addBook(new Book.AddBook(new Book.Barcode("12345"), "Test Book", "1234567890")); 102 | book.markOnHold(); 103 | when(bookRepository.findOnHoldBook(any())).thenReturn(Optional.of(book)); 104 | when(bookRepository.save(any())).thenReturn(book); 105 | Hold.BookCheckedOut event = new BookCheckedOut(book.getId().id(), book.getInventoryNumber().barcode(), LocalDate.now()); 106 | 107 | // Act 108 | circulationDesk.handle(event); 109 | 110 | // Assert 111 | assertThat(book.getStatus()).isEqualTo(ISSUED); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 4.0.0 9 | 10 | 11 | com.abhinav 12 | spring-modulith-with-ddd 13 | 0.0.1-SNAPSHOT 14 | spring-modulith-with-ddd 15 | Spring Modulith with DDD 16 | 17 | 23 18 | 3.14.1 19 | 3.0.0 20 | 2.0.0 21 | 2025.0.1 22 | 1.4.1 23 | 24 | 25 | 26 | 27 | org.springframework.boot 28 | spring-boot-starter-webmvc 29 | 30 | 31 | org.springframework.boot 32 | spring-boot-starter-data-jpa 33 | 34 | 35 | org.springframework.boot 36 | spring-boot-starter-security-oauth2-resource-server 37 | 38 | 39 | org.springframework.modulith 40 | spring-modulith-starter-core 41 | 42 | 43 | org.springframework.modulith 44 | spring-modulith-starter-jpa 45 | 46 | 47 | org.springdoc 48 | springdoc-openapi-starter-webmvc-ui 49 | ${springdoc-openapi-starter-webmvc-ui.version} 50 | 51 | 52 | org.jmolecules 53 | jmolecules-layered-architecture 54 | 55 | 56 | org.jmolecules 57 | jmolecules-hexagonal-architecture 58 | 59 | 60 | org.jmolecules 61 | jmolecules-onion-architecture 62 | 63 | 64 | org.jmolecules 65 | jmolecules-ddd 66 | 67 | 68 | 69 | com.h2database 70 | h2 71 | runtime 72 | 73 | 74 | org.projectlombok 75 | lombok 76 | true 77 | 78 | 79 | 80 | 81 | org.springframework.boot 82 | spring-boot-starter-security-oauth2-resource-server-test 83 | test 84 | 85 | 86 | org.springframework.modulith 87 | spring-modulith-starter-test 88 | test 89 | 90 | 91 | org.jmolecules.integrations 92 | jmolecules-archunit 93 | test 94 | 95 | 96 | com.tngtech.archunit 97 | archunit-junit5 98 | ${archunit.version} 99 | test 100 | 101 | 102 | 103 | 104 | 105 | 106 | org.springframework.modulith 107 | spring-modulith-bom 108 | ${spring-modulith-bom.version} 109 | pom 110 | import 111 | 112 | 113 | org.jmolecules 114 | jmolecules-bom 115 | ${jmolecules-bom.version} 116 | pom 117 | import 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | org.apache.maven.plugins 126 | maven-compiler-plugin 127 | ${maven-compiler-plugin.version} 128 | 129 | 130 | 131 | org.projectlombok 132 | lombok 133 | ${lombok.version} 134 | 135 | 136 | 137 | 138 | 139 | org.springframework.boot 140 | spring-boot-maven-plugin 141 | 142 | 143 | 144 | paketobuildpacks/java 145 | 146 | paketobuildpacks/builder-jammy-buildpackless-tiny 147 | 148 | false 149 | false 150 | true 151 | true 152 | 25 153 | 154 | 155 | 156 | 157 | org.projectlombok 158 | lombok 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Building Modular Monolith Applications with Spring Modulith and Domain Driven Design 2 | 3 | This repository showcases a Modular Monolith implementation of borrowing books in a library with 4 | Spring Boot, Spring Modulith and Domain Driven Design principles. 5 | 6 | The code is explained in a series of blog posts. 7 | 8 | 1. [Building Modular Monolith Applications with Spring Boot and Domain Driven Design](https://itnext.io/building-modular-monolith-applications-with-spring-boot-and-domain-driven-design-d3299b300850?sk=3c3179d82508b50cc490a2a47074804f) - First attempt at building a Modular Monolith code with only Spring Boot and DDD (does not use Spring Modulith). The code is available in branch [part-1-ddd-solution](https://github.com/xsreality/spring-modulith-with-ddd/tree/part-1-ddd-solution). 9 | 2. [Improving Modular Monolith Applications with Spring Modulith](https://itnext.io/improving-modular-monolith-applications-with-spring-modulith-edecc787f63c?sk=051ea353e17154843886705fb90ed64a) - In this blog, we rethink the domain model and apply eventual consistency with Spring Modulith to make the application easier to test, self-documenting and more maintainable. The code is available in branch [part-2-spring-modulith](https://github.com/xsreality/spring-modulith-with-ddd/tree/part-2-spring-modulith). 10 | 3. [Adopting Domain-First Thinking in Modular Monolith with Hexagonal Architecture](https://itnext.io/adopting-domain-first-thinking-in-modular-monolith-with-hexagonal-architecture-f9e4921ac18d?sk=9364f2aac410c7b72e75e189bfa240e9) - In this blog, we re-implement the Borrow module with Hexagonal instead of Layered architecture. We demonstrate how absolutely no changes were needed in the Catalog module even though they are part of the same monolith code base. The code is available in branch [part-3-hexagonal-architecture](https://github.com/xsreality/spring-modulith-with-ddd/tree/part-3-hexagonal-architecture). 11 | 4. [Securing Modular Monolith with OAuth2 and Spring Security](https://itnext.io/securing-modular-monolith-with-oauth2-and-spring-security-43f2504c4e2e?sk=d70b9e7b343a2d0b690272d3b153dae3) - In this blog, we add OAuth2 authentication and authorization with Spring Security. Using Spring Modulith _shared_ module concept, we create a shared security module. The code is available in branch [part-4-authentication](https://github.com/xsreality/spring-modulith-with-ddd/tree/part-4-authentication) and `main`. 12 | 13 | ## Project Requirements 14 | 15 | * JDK 23 16 | * Spring Boot 4 (Spring Boot 3 version is available in branch [spring-boot-3](https://github.com/xsreality/spring-modulith-with-ddd/tree/spring-boot-3)) 17 | 18 | ## The Business Problem 19 | 20 | 1. The library consists of thousands of books. There can be multiple copies of the same book. 21 | 2. Before being included in the library, every book receives a barcode stamped at the back or one of the end pages. This barcode number uniquely identifies the book. 22 | 3. A patron of the library can make a request to place a book on hold by either locating the book in the library or directly going to the circulation desk and ask for a book by title. If book is available, the patron can proceed to checkout (collect) the book. 23 | 4. A patron cannot check out a book held by a different patron. 24 | 5. The book is checked out for a fixed period of 2 weeks. 25 | 6. To check in (return) the book, the patron can go to the circulation desk or drop it in the drop zone. 26 | 7. Only staff members (users with role `ROLE_STAFF`) can add a book to the catalog. 27 | 28 | ## Bounded Contexts 29 | 30 | ![image](https://github.com/xsreality/spring-modulith-with-ddd/assets/4991449/8b91d3c7-b65d-4f2e-89bd-0e9db63bacfb) 31 | 32 | ## Prepare the application 33 | 34 | To compile and build the docker images, run below command: 35 | 36 | ```bash 37 | mvn spring-boot:build-image 38 | ``` 39 | 40 | This will generate a docker image locally - `spring-modulith-with-ddd:0.0.1-SNAPSHOT`. 41 | 42 | ## Run the application 43 | 44 | The project comes with a docker compose file which spins up the application as well as Keycloak, the Authorization server for OAuth2 flow. After completing the steps in "Prepare the application", run below command to start the application: 45 | 46 | ```bash 47 | docker-compose up 48 | ``` 49 | 50 | ## Authentication 51 | 52 | The project uses OAuth2 flows for authentication implemented with Spring Security. The Authorization server is Keycloak (installed automatically via docker compose). The Authorization Server Metadata can be accessed at http://localhost:8083/realms/library/.well-known/openid-configuration. 53 | 54 | Keycloak is preconfigured with a realm named `library`. It has 2 users - `john.wick@continental.com` and `winston@continental.com` with the credentials `password`. A public client with client ID `library` is also configured to trigger the Authorization code flow. 55 | 56 | An access token can be obtained by using any client like Postman or Insomnia to trigger the Authorization code flow. 57 | 58 | ## Swagger REST API Docs 59 | Access the Swagger UI at http://localhost:8080/swagger-ui/index.html 60 | 61 | ![image](https://github.com/xsreality/spring-modulith-with-ddd/assets/4991449/fcfb3e49-3024-4850-ba6e-dfeb9211caff) 62 | 63 | ## Examples 64 | 65 | ### Add book to Library 66 | ```bash 67 | curl -X POST \ 68 | --url http://localhost:8080/catalog/books \ 69 | --header 'Content-Type: application/json' \ 70 | --data '{ 71 | "title": "Sapiens", 72 | "catalogNumber": "12345", 73 | "isbn": "9780062316097", 74 | "author": "Yuval Noah Harari" 75 | }' 76 | ``` 77 | 78 | Response: 79 | ```json 80 | { 81 | "id": 1, 82 | "title": "Sapiens", 83 | "catalogNumber": { 84 | "barcode": "12345" 85 | }, 86 | "isbn": "9780062316097", 87 | "author": { 88 | "name": "Yuval Noah Harari" 89 | } 90 | } 91 | ``` 92 | 93 | ### Place a book on hold (start the borrowing process) 94 | 95 | ```bash 96 | curl -X POST \ 97 | --url http://localhost:8080/borrow/holds \ 98 | --header 'Content-Type: application/json' \ 99 | --data '{ 100 | "barcode": "12345" 101 | }' 102 | ``` 103 | 104 | Response: 105 | ```json 106 | { 107 | "id": "8c8702af-9363-4953-94a5-2ddfa5aea631", 108 | "bookBarcode": "12345", 109 | "patronId": "john.wick@continental.com", 110 | "dateOfHold": "2024-05-27" 111 | } 112 | ``` 113 | 114 | ### Check out the book 115 | 116 | ```bash 117 | curl -X POST \ 118 | --url http://localhost:8080/borrow/holds/8c8702af-9363-4953-94a5-2ddfa5aea631/checkout 119 | ``` 120 | 121 | Response: 122 | ```json 123 | { 124 | "holdId": "202c58fa-feee-4c74-96e4-553600160693", 125 | "patronId": "john.wick@continental.com", 126 | "dateOfCheckout": "2024-02-25" 127 | } 128 | ``` 129 | -------------------------------------------------------------------------------- /keycloak-realm/library-realm.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "021b76be-fd6b-466c-82a9-8530927126d7", 3 | "realm": "library", 4 | "notBefore": 0, 5 | "defaultSignatureAlgorithm": "RS256", 6 | "revokeRefreshToken": false, 7 | "refreshTokenMaxReuse": 0, 8 | "accessTokenLifespan": 300, 9 | "accessTokenLifespanForImplicitFlow": 900, 10 | "ssoSessionIdleTimeout": 1800, 11 | "ssoSessionMaxLifespan": 36000, 12 | "ssoSessionIdleTimeoutRememberMe": 0, 13 | "ssoSessionMaxLifespanRememberMe": 0, 14 | "offlineSessionIdleTimeout": 2592000, 15 | "offlineSessionMaxLifespanEnabled": false, 16 | "offlineSessionMaxLifespan": 5184000, 17 | "clientSessionIdleTimeout": 0, 18 | "clientSessionMaxLifespan": 0, 19 | "clientOfflineSessionIdleTimeout": 0, 20 | "clientOfflineSessionMaxLifespan": 0, 21 | "accessCodeLifespan": 60, 22 | "accessCodeLifespanUserAction": 300, 23 | "accessCodeLifespanLogin": 1800, 24 | "actionTokenGeneratedByAdminLifespan": 43200, 25 | "actionTokenGeneratedByUserLifespan": 300, 26 | "oauth2DeviceCodeLifespan": 600, 27 | "oauth2DevicePollingInterval": 5, 28 | "enabled": true, 29 | "sslRequired": "external", 30 | "registrationAllowed": false, 31 | "registrationEmailAsUsername": false, 32 | "rememberMe": false, 33 | "verifyEmail": false, 34 | "loginWithEmailAllowed": true, 35 | "duplicateEmailsAllowed": false, 36 | "resetPasswordAllowed": false, 37 | "editUsernameAllowed": false, 38 | "bruteForceProtected": false, 39 | "permanentLockout": false, 40 | "maxTemporaryLockouts": 0, 41 | "maxFailureWaitSeconds": 900, 42 | "minimumQuickLoginWaitSeconds": 60, 43 | "waitIncrementSeconds": 60, 44 | "quickLoginCheckMilliSeconds": 1000, 45 | "maxDeltaTimeSeconds": 43200, 46 | "failureFactor": 30, 47 | "roles": { 48 | "realm": [ 49 | { 50 | "id": "903f1e33-91e0-46bb-9413-c79416e5375c", 51 | "name": "uma_authorization", 52 | "description": "${role_uma_authorization}", 53 | "composite": false, 54 | "clientRole": false, 55 | "containerId": "021b76be-fd6b-466c-82a9-8530927126d7", 56 | "attributes": {} 57 | }, 58 | { 59 | "id": "57dd89a6-6f0d-4794-8975-c615a50deef4", 60 | "name": "offline_access", 61 | "description": "${role_offline-access}", 62 | "composite": false, 63 | "clientRole": false, 64 | "containerId": "021b76be-fd6b-466c-82a9-8530927126d7", 65 | "attributes": {} 66 | }, 67 | { 68 | "id": "f6befd06-c73b-40de-8b37-c5e85cce395b", 69 | "name": "default-roles-library", 70 | "description": "${role_default-roles}", 71 | "composite": true, 72 | "composites": { 73 | "realm": [ 74 | "offline_access", 75 | "uma_authorization" 76 | ], 77 | "client": { 78 | "account": [ 79 | "manage-account", 80 | "view-profile" 81 | ] 82 | } 83 | }, 84 | "clientRole": false, 85 | "containerId": "021b76be-fd6b-466c-82a9-8530927126d7", 86 | "attributes": {} 87 | }, 88 | { 89 | "id": "72d4fbcc-517f-41b8-82cc-af0a0168307b", 90 | "name": "ROLE_STAFF", 91 | "description": "Library staff allowed to manage books catalog.", 92 | "composite": false, 93 | "clientRole": false, 94 | "containerId": "021b76be-fd6b-466c-82a9-8530927126d7", 95 | "attributes": {} 96 | } 97 | ] 98 | }, 99 | "users" : [ { 100 | "id" : "2dab8dba-fdb6-43ef-916e-f041badd64d9", 101 | "username" : "john", 102 | "firstName" : "John", 103 | "lastName" : "Wick", 104 | "email" : "john.wick@continental.com", 105 | "emailVerified" : false, 106 | "createdTimestamp" : 1715547071530, 107 | "enabled" : true, 108 | "totp" : false, 109 | "credentials" : [ { 110 | "id" : "65bbb036-3ecd-4942-9c4b-77acb66b4924", 111 | "type" : "password", 112 | "userLabel" : "My password", 113 | "createdDate" : 1715550620530, 114 | "secretData" : "{\"value\":\"xljCCtA5/oGGH9CCpNwXswcQAqP30q3xa0Sff1yS9EIIaRyxPwcr3ugkmhyjpmzfHHNuuRmANcZv9UkJX0T2YA==\",\"salt\":\"tfH6V7GRncMZjjq3ex9dBg==\",\"additionalParameters\":{}}", 115 | "credentialData" : "{\"hashIterations\":210000,\"algorithm\":\"pbkdf2-sha512\",\"additionalParameters\":{}}" 116 | } ], 117 | "disableableCredentialTypes" : [ ], 118 | "requiredActions" : [ ], 119 | "realmRoles" : [ "default-roles-library" ], 120 | "notBefore" : 0, 121 | "groups" : [ ] 122 | }, { 123 | "id" : "a0bdcbc5-1338-4573-95e8-9f9edd104d38", 124 | "username" : "winston", 125 | "firstName" : "Winston", 126 | "lastName" : "Continental", 127 | "email" : "winston@continental.com", 128 | "emailVerified" : false, 129 | "createdTimestamp" : 1715547177831, 130 | "enabled" : true, 131 | "totp" : false, 132 | "credentials" : [ { 133 | "id" : "468e237c-4c68-4bbf-8fc8-84fa31e6580f", 134 | "type" : "password", 135 | "userLabel" : "My password", 136 | "createdDate" : 1715551054976, 137 | "secretData" : "{\"value\":\"f322dM2ytpSZZTHhaPB617IJmPvgDzYg+ztz3TrEdPCWB94EitJ7G1PVfM+6YKR9nPByVmKPU/n6MrkIgN7ITA==\",\"salt\":\"HqP3nIj2hZx73zqfBkbQaw==\",\"additionalParameters\":{}}", 138 | "credentialData" : "{\"hashIterations\":210000,\"algorithm\":\"pbkdf2-sha512\",\"additionalParameters\":{}}" 139 | } ], 140 | "disableableCredentialTypes" : [ ], 141 | "requiredActions" : [ ], 142 | "realmRoles" : [ "default-roles-library", "ROLE_STAFF" ], 143 | "notBefore" : 0, 144 | "groups" : [ ] 145 | } ], 146 | "groups": [], 147 | "defaultRole": { 148 | "id": "f6befd06-c73b-40de-8b37-c5e85cce395b", 149 | "name": "default-roles-library", 150 | "description": "${role_default-roles}", 151 | "composite": true, 152 | "clientRole": false, 153 | "containerId": "021b76be-fd6b-466c-82a9-8530927126d7" 154 | }, 155 | "requiredCredentials": [ 156 | "password" 157 | ], 158 | "clients" : [ { 159 | "id" : "3eeed558-7fdd-4d03-a0e1-4639a0d842e3", 160 | "clientId" : "library", 161 | "name" : "Library", 162 | "description" : "", 163 | "rootUrl" : "", 164 | "adminUrl" : "", 165 | "baseUrl" : "", 166 | "surrogateAuthRequired" : false, 167 | "enabled" : true, 168 | "alwaysDisplayInConsole" : false, 169 | "clientAuthenticatorType" : "client-secret", 170 | "redirectUris" : [ "http://localhost:8080", "http://localhost" ], 171 | "webOrigins" : [ "http://localhost:8080", "http://localhost" ], 172 | "notBefore" : 0, 173 | "bearerOnly" : false, 174 | "consentRequired" : false, 175 | "standardFlowEnabled" : true, 176 | "implicitFlowEnabled" : false, 177 | "directAccessGrantsEnabled" : true, 178 | "serviceAccountsEnabled" : false, 179 | "publicClient" : true, 180 | "frontchannelLogout" : true, 181 | "protocol" : "openid-connect", 182 | "attributes" : { 183 | "oidc.ciba.grant.enabled" : "false", 184 | "oauth2.device.authorization.grant.enabled" : "false", 185 | "backchannel.logout.session.required" : "true", 186 | "backchannel.logout.revoke.offline.tokens" : "false" 187 | }, 188 | "authenticationFlowBindingOverrides" : { }, 189 | "fullScopeAllowed" : true, 190 | "nodeReRegistrationTimeout" : -1, 191 | "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "email" ], 192 | "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] 193 | } ] 194 | } --------------------------------------------------------------------------------