├── .editorconfig ├── .env ├── .env.dev ├── .env.test ├── .github └── workflows │ └── app.yml ├── .gitignore ├── README.md ├── bin ├── console └── phpunit ├── compose.override.yaml ├── compose.yaml ├── composer.json ├── composer.lock ├── config ├── bundles.php ├── packages │ ├── cache.yaml │ ├── doctrine.yaml │ ├── doctrine_migrations.yaml │ ├── framework.yaml │ ├── routing.yaml │ └── validator.yaml ├── preload.php ├── routes.yaml ├── routes │ └── framework.yaml ├── services.yaml └── validator │ ├── catalogue.yaml │ └── order.yaml ├── deptrac.yaml ├── migrations ├── .gitignore ├── Version20250824123814.php ├── Version20250824124219.php └── Version20250830094856.php ├── phpunit.dist.xml ├── public └── index.php ├── src ├── Catalogue │ ├── Application │ │ ├── Command │ │ │ ├── CommandValidatorInterface.php │ │ │ ├── CreateProductCommand.php │ │ │ ├── FulfillStockReservationCommand.php │ │ │ ├── Handler │ │ │ │ ├── CreateProductCommandHandler.php │ │ │ │ ├── FulfillStockReservationCommandHandler.php │ │ │ │ └── ReserveStockCommandHandler.php │ │ │ └── ReserveStockCommand.php │ │ └── Query │ │ │ ├── GetProductsQuery.php │ │ │ └── Handler │ │ │ └── GetProductsQueryHandler.php │ ├── Contracts │ │ └── Reservation │ │ │ ├── CatalogueFulfillReservationRequest.php │ │ │ ├── CatalogueReservationResult.php │ │ │ ├── CatalogueReserveStockRequest.php │ │ │ ├── CatalogueStockReservationFulfillmentPort.php │ │ │ ├── CatalogueStockReservationPort.php │ │ │ └── FulfillReservationResult.php │ ├── Domain │ │ ├── Entity │ │ │ └── Product.php │ │ ├── Exception │ │ │ ├── EmptyProductNameException.php │ │ │ ├── InsufficientStockException.php │ │ │ ├── NonPositiveQuantityException.php │ │ │ └── OverCommitReservationException.php │ │ └── Repository │ │ │ └── ProductRepositoryInterface.php │ ├── Infrastructure │ │ ├── Ohs │ │ │ ├── CatalogueStockReservationFulfillmentService.php │ │ │ └── CatalogueStockReservationService.php │ │ ├── Persistence │ │ │ └── Doctrine │ │ │ │ ├── Mapping │ │ │ │ └── Product.orm.xml │ │ │ │ └── Repository │ │ │ │ └── ProductRepository.php │ │ └── Validation │ │ │ └── SymfonyCommandValidator.php │ └── Presentation │ │ └── Http │ │ └── Controller │ │ └── ProductController.php ├── Kernel.php ├── Order │ ├── Application │ │ ├── Command │ │ │ ├── CommandValidatorInterface.php │ │ │ ├── CreateOrderCommand.php │ │ │ └── Handler │ │ │ │ ├── CreateOrderCommandHandler.php │ │ │ │ └── FulfillOrderCommandHandler.php │ │ ├── Port │ │ │ ├── Dto │ │ │ │ ├── FulfillReservationRequest.php │ │ │ │ ├── ReservationFulfilmentResult.php │ │ │ │ ├── ReservationRequest.php │ │ │ │ └── ReservationResult.php │ │ │ ├── StockReservationFulfilmentPort.php │ │ │ └── StockReservationPort.php │ │ └── Query │ │ │ ├── GetOrdersQuery.php │ │ │ └── Handler │ │ │ └── GetOrdersQueryHandler.php │ ├── Domain │ │ ├── Entity │ │ │ ├── Order.php │ │ │ └── OrderItem.php │ │ ├── Enum │ │ │ └── OrderStatus.php │ │ ├── Exception │ │ │ ├── InvalidOrderStateTransitionException.php │ │ │ ├── NonPositiveOrderAmountException.php │ │ │ └── OrderItemsNotReservedException.php │ │ ├── Repository │ │ │ └── OrderRepositoryInterface.php │ │ └── ValueObject │ │ │ └── OrderLine.php │ ├── Infrastructure │ │ ├── Persistence │ │ │ └── Doctrine │ │ │ │ ├── Mapping │ │ │ │ ├── Order.orm.xml │ │ │ │ └── OrderItem.orm.xml │ │ │ │ └── Repository │ │ │ │ └── OrderRepository.php │ │ └── Validation │ │ │ └── SymfonyCommandValidator.php │ ├── Integration │ │ └── Catalogue │ │ │ ├── StockReservationAdapter.php │ │ │ └── StockReservationFulfilmentAdapter.php │ └── Presentation │ │ └── Http │ │ └── Controller │ │ └── OrderController.php └── SharedKernel │ ├── Domain │ ├── Persistence │ │ └── TransactionRunnerInterface.php │ └── ValueObject │ │ └── Money.php │ ├── Http │ └── ResponseEnvelope.php │ └── Infrastructure │ ├── Http │ ├── Exception │ │ └── ExceptionListener.php │ └── SymfonyErrorResponder.php │ └── Persistence │ └── Doctrine │ └── DoctrineTransactionRunner.php ├── symfony.lock └── tests ├── Application ├── Command │ ├── CreateProductCommandHandlerTest.php │ ├── FulfillStockReservationCommandHandlerTest.php │ └── ReserveStockCommandHandlerTest.php └── Query │ └── GetProductsQueryHandlerTest.php ├── Domain ├── OrderItemTest.php ├── OrderTest.php └── ProductTest.php ├── Support ├── InMemoryProductRepository.php └── InMemoryTransactionRunner.php └── bootstrap.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 4 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [{compose.yaml,compose.*.yaml}] 14 | indent_size = 2 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # https://symfony.com/doc/current/configuration/secrets.html 13 | # 14 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 15 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 16 | 17 | ###> symfony/framework-bundle ### 18 | APP_ENV=dev 19 | APP_SECRET= 20 | ###< symfony/framework-bundle ### 21 | 22 | ###> doctrine/doctrine-bundle ### 23 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 24 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml 25 | # 26 | # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" 27 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" 28 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" 29 | DATABASE_URL="mysql://root:me4CcwJm7J@mysql:3306/products_ddd" 30 | ###< doctrine/doctrine-bundle ### 31 | -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | 2 | ###> symfony/framework-bundle ### 3 | APP_SECRET=404d8419392c3141daa1bff9dafbf40d 4 | ###< symfony/framework-bundle ### 5 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # define your env variables for the test env here 2 | KERNEL_CLASS='App\Kernel' 3 | APP_SECRET='$ecretf0rt3st' 4 | -------------------------------------------------------------------------------- /.github/workflows/app.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | tests: 14 | name: Run Symfony tests 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: '8.2' 23 | coverage: none 24 | 25 | - name: Cache Composer packages 26 | uses: actions/cache@v3 27 | with: 28 | path: vendor 29 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 30 | restore-keys: | 31 | ${{ runner.os }}-php- 32 | 33 | - name: Install dependencies 34 | run: composer install --prefer-dist --no-progress --no-suggest --no-interaction 35 | 36 | - name: Run PHPUnit tests 37 | env: 38 | DATABASE_URL: sqlite:///%kernel.project_dir%/data/database.sqlite 39 | run: vendor/bin/phpunit 40 | 41 | - name: Run Deptrac 42 | run: vendor/bin/deptrac 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ###> symfony/framework-bundle ### 3 | /.env.local 4 | /.env.local.php 5 | /.env.*.local 6 | /config/secrets/prod/prod.decrypt.private.php 7 | /public/bundles/ 8 | /var/ 9 | /vendor/ 10 | ###< symfony/framework-bundle ### 11 | /.idea 12 | ###> phpstan/phpstan ### 13 | phpstan.neon 14 | ###< phpstan/phpstan ### 15 | 16 | ###> qossmic/deptrac ### 17 | /.deptrac.cache 18 | ###< qossmic/deptrac ### 19 | 20 | ###> phpunit/phpunit ### 21 | /phpunit.xml 22 | /.phpunit.cache/ 23 | ###< phpunit/phpunit ### 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Symfony Domain Driven Design (DDD) and Clean Architecture 2 | 3 | ![Tests](https://github.com/vantukh-kolya/symfony-ddd/actions/workflows/app.yml/badge.svg) 4 | 5 | This repository is a **Symfony 7 demo project** built to showcase **Domain-Driven Design (DDD)**, **Clean Architecture**, and strict **dependency rules** enforced by **Deptrac**. 6 | It is not a production system - the goal is to demonstrate architecture in Symfony app. 7 | 8 | --- 9 | 10 | 11 | ## Project Structure 12 | 13 | ``` 14 | src 15 | ├── Catalogue/ # Catalogue Bounded Context 16 | │ ├── Application/ # Use cases (commands, queries, handlers) 17 | │ │ ├── Command/ 18 | │ │ │ ├── CommandValidatorInterface.php # Port for command validation 19 | │ │ │ ├── CreateProductCommand.php 20 | │ │ │ ├── FulfillStockReservationCommand.php 21 | │ │ │ ├── ReserveStockCommand.php 22 | │ │ │ └── Handler/ # Handlers orchestrating domain logic 23 | │ │ └── Query/ 24 | │ │ ├── GetProductsQuery.php 25 | │ │ └── Handler/GetProductsQueryHandler.php 26 | │ │ 27 | │ ├── Contracts/ # Published Language 28 | │ │ └── Reservation/ # DTOs and ports exposed to other BCs 29 | │ │ ├── CatalogueReserveStockRequest.php 30 | │ │ ├── CatalogueReservationResult.php 31 | │ │ ├── CatalogueReservationPort.php 32 | │ │ └── CatalogueReservationFulfillmentPort.php 33 | │ │ 34 | │ ├── Domain/ # Pure domain model (entities, repos, exceptions) 35 | │ │ ├── Entity/ 36 | │ │ ├── Repository/ 37 | │ │ └── Exception/ 38 | │ │ 39 | │ ├── Infrastructure/ 40 | │ │ ├── Ohs/ # Implementations of the Published Language (Contracts) 41 | │ │ │ ├── CatalogueStockReservationService.php 42 | │ │ │ └── CatalogueStockReservationFulfillmentService.php 43 | │ │ ├── Persistence/Doctrine/ # Doctrine mappings and repositories 44 | │ │ └── Validation/ # Symfony adapter for CommandValidatorInterface 45 | │ │ └── SymfonyCommandValidator.php 46 | │ │ 47 | │ └── Presentation/Http/Controller/ 48 | │ └── ProductController.php # API entry points 49 | │ 50 | ├── Order/ # Order Bounded Context 51 | │ ├── Application/ 52 | │ │ ├── Command/ 53 | │ │ │ ├── CommandValidatorInterface.php # Port for command validation 54 | │ │ │ └── Handler/ 55 | │ │ │ └── CreateOrderCommandHandler.php 56 | │ │ ├── Port/ # Ports (interfaces, DTOs) 57 | │ │ │ ├── Dto/ 58 | │ │ │ │ ├── ReservationRequest.php 59 | │ │ │ │ ├── ReservationResult.php 60 | │ │ │ │ └── FulfillReservationRequest.php 61 | │ │ │ ├── StockReservationPort.php 62 | │ │ │ └── StockReservationFulfillmentPort.php 63 | │ │ └── Query/ 64 | │ │ └── GetOrdersQuery.php 65 | │ │ 66 | │ ├── Domain/ # Pure Order model 67 | │ │ 68 | │ ├── Infrastructure/ 69 | │ │ ├── Persistence/Doctrine/ # Doctrine mappings and repositories 70 | │ │ │ ├── Mapping/Order.orm.xml 71 | │ │ │ └── Mapping/OrderItem.orm.xml 72 | │ │ └── Validation/ # Symfony adapter for CommandValidatorInterface 73 | │ │ └── SymfonyCommandValidator.php 74 | │ │ 75 | │ ├── Integration/ # Anti-corruption layer to other BCs 76 | │ │ └── Catalogue/ 77 | │ │ ├── StockReservationAdapter.php 78 | │ │ └── StockReservationFulfillmentAdapter.php 79 | │ │ 80 | │ └── Presentation/ # HTTP/CLI controllers if needed 81 | │ 82 | └── SharedKernel/ # Cross-cutting primitives and adapters 83 | │ ├── Domain/ 84 | │ │ ├── Persistence/TransactionRunnerInterface.php 85 | │ │ └── ValueObject/Money.php 86 | │ ├── Http/ # Transport-agnostic HTTP helpers (no Symfony) 87 | │ │ └── ResponseEnvelope.php # Unified success/error envelope (data/meta|error) 88 | │ └── Infrastructure/ 89 | │ │ └── Http/ 90 | │ │ ├── SymfonyErrorResponder.php 91 | │ │ └── Exception/ExceptionListener.php # Maps exceptions → ResponseEnvelope/JsonResponse 92 | └── ├── Persistence/Doctrine/DoctrineTransactionRunner.php 93 | 94 | ``` 95 | 96 | --- 97 | 98 | ## Bounded Contexts 99 | 100 | - **Order BC** 101 | Manages customer orders, statuses, fulfillment, and reservation workflow. 102 | 103 | - **Catalogue BC** 104 | Manages products and stock. Provides APIs for stock reservation and committing. 105 | 106 | - **Shared Kernel** 107 | Common primitives 108 | 109 | 110 | --- 111 | 112 | ## Layers 113 | 114 | Each bounded context is split into four layers: 115 | 116 | - **Domain** 117 | Pure business logic: entities, aggregates, value objects, invariants. 118 | No dependencies on Symfony, Doctrine, or infrastructure. 119 | 120 | - **Application** 121 | Use-cases: Commands, Queries, Handlers, Ports. 122 | Orchestrates the Domain, calls external services through ports. 123 | 124 | - **Infrastructure** 125 | Technical implementations: Doctrine repositories, framework glue. 126 | May depend on Symfony, Doctrine, external systems. 127 | 128 | - **Presentation** 129 | Entry points: HTTP controllers, CLI commands. Call Application use-cases directly. 130 | 131 | - **Integration (in Order BC)** 132 | Anti-Corruption Layer to integrate with Catalogue. Depends only on Contracts. 133 | 134 | - **Contracts (in Catalogue BC)** 135 | Published Language (DTOs, service interfaces) to be consumed by other BCs. 136 | 137 | --- 138 | 139 | ## Database Transaction Management 140 | 141 | Transactions are abstracted via the `TransactionRunnerInterface` in the **Shared Kernel**. 142 | - **Application handlers** orchestrate use-cases inside a transaction. 143 | - **Infrastructure** provides the implementation (e.g. Doctrine). 144 | - This keeps the **Domain** pure and independent of persistence details. 145 | 146 | Example (pseudocode): 147 | 148 | ```php 149 | $this->transactionRunner->run(function () use ($command) { 150 | $order = Order::create(...); 151 | $this->orderRepository->add($order); 152 | }); 153 | ``` 154 | 155 | ## Dependency Rules (Deptrac) 156 | 157 | Strict boundaries are enforced by Deptrac. 158 | 159 | ``` 160 | Domain -> SharedDomain only 161 | Application -> Domain, SharedDomain (and Contracts in Catalogue) 162 | Infrastructure -> Domain, Application, SharedDomain, Doctrine, Framework 163 | Presentation -> Application, Framework 164 | Integration (Order) -> Application, Contracts 165 | ``` 166 | 167 | See [`deptrac.yaml`](./deptrac.yaml) for full config. 168 | 169 | --- 170 | 171 | ## Example: Order flow 172 | 173 | **1. Controller → Application** 174 | 175 | ```php 176 | $command = new CreateOrderCommand($uuid, $amount, $products); 177 | $order = $handler($command); 178 | ``` 179 | 180 | **2. Application → Domain** 181 | `Order::create()` builds aggregate with items and invariants. 182 | 183 | **3. Application → Integration** 184 | `StockReservationPort.reserve()` delegated to Catalogue Contracts. 185 | 186 | **4. Integration → Contracts → Catalogue Application** 187 | Catalogue validates request and holds product stock. 188 | 189 | --- 190 | 191 | ## Cross-BC Communication 192 | 193 | - **OrderIntegration** implements `StockReservationPort` using `Catalogue\Contracts\Reservation\CatalogueStockReservationPort`. 194 | - **CatalogueContracts** defines the Published Language. 195 | - This decouples Order from Catalogue’s internals. If Catalogue is extracted into a microservice (REST/Message broker), Order only needs to re-wire the adapter. 196 | 197 | ## Application Layer: Commands & Queries 198 | 199 | The Application layer is organized around **explicit use-cases** that act as **entry points into the application**: 200 | 201 | - **Command/Handler (write side)** 202 | - Commands are simple DTOs with scalar input (e.g. `CreateOrderCommand`). 203 | - Handlers orchestrate Domain operations and run inside a transaction. 204 | - Example: creating an order, reserving stock. 205 | 206 | - **Query/Handler (read side)** 207 | - Queries are DTOs describing a read request (e.g. `GetOrderByIdQuery`). 208 | - Handlers fetch and return DTOs/arrays optimized for presentation. 209 | - Example: fetching order details, listing catalogue products. 210 | 211 | Controllers or OHS services construct a Command/Query and invoke its Handler directly. 212 | This makes **Application Handlers the clear entry points for all business use-cases**, while keeping the Domain isolated and pure. 213 | 214 | ## Endpoints 215 | 216 | ### Catalogue 217 | - `POST /api/products` Create a new product. 218 | **Body:** `{ "id": "uuid", "name": "string", "price": 1000, "onHand": 1000 }` 219 | 220 | - `GET /api/products` Get products 221 | 222 | ### Orders 223 | - `POST /api/orders` 224 | Create a new order and reserve products. 225 | **Body:** `{ "id": "uuid", "amount_to_pay": 1500, "products": [...] }` 226 | 227 | - `GET /api/orders` Get orders 228 | 229 | - `POST /api/orders/{orderId}/fulfill` Fulfill order 230 | 231 | ## Tests 232 | 233 | The project includes PHPUnit tests and architecture validation: 234 | 235 | - **Domain tests** 236 | Verify business rules and invariants in the Domain layer. 237 | ```bash 238 | php bin/phpunit tests/Domain 239 | - **Application tests** 240 | Validate use-case handlers with in-memory repositories. 241 | ```bash 242 | php bin/phpunit tests/Application 243 | - **Deptrac (architecture tests)** 244 | Enforces strict dependency rules between layers (Domain, Application, Infrastructure, etc.). 245 | Run with: 246 | ```bash 247 | vendor/bin/deptrac analyse 248 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | = 80000) { 10 | require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit'; 11 | } else { 12 | define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php'); 13 | require PHPUNIT_COMPOSER_INSTALL; 14 | PHPUnit\TextUI\Command::main(); 15 | } 16 | } else { 17 | if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) { 18 | echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n"; 19 | exit(1); 20 | } 21 | 22 | require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php'; 23 | } 24 | -------------------------------------------------------------------------------- /compose.override.yaml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | ###> doctrine/doctrine-bundle ### 4 | database: 5 | ports: 6 | - "5432" 7 | ###< doctrine/doctrine-bundle ### 8 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | ###> doctrine/doctrine-bundle ### 4 | database: 5 | image: postgres:${POSTGRES_VERSION:-16}-alpine 6 | environment: 7 | POSTGRES_DB: ${POSTGRES_DB:-app} 8 | # You should definitely change the password in production 9 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} 10 | POSTGRES_USER: ${POSTGRES_USER:-app} 11 | healthcheck: 12 | test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"] 13 | timeout: 5s 14 | retries: 5 15 | start_period: 60s 16 | volumes: 17 | - database_data:/var/lib/postgresql/data:rw 18 | # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! 19 | # - ./docker/db/data:/var/lib/postgresql/data:rw 20 | ###< doctrine/doctrine-bundle ### 21 | 22 | volumes: 23 | ###> doctrine/doctrine-bundle ### 24 | database_data: 25 | ###< doctrine/doctrine-bundle ### 26 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "project", 3 | "license": "proprietary", 4 | "minimum-stability": "stable", 5 | "prefer-stable": true, 6 | "require": { 7 | "php": ">=8.2", 8 | "ext-ctype": "*", 9 | "ext-iconv": "*", 10 | "doctrine/dbal": "^3", 11 | "doctrine/doctrine-bundle": "^2.15", 12 | "doctrine/doctrine-migrations-bundle": "^3.4", 13 | "doctrine/orm": "^3.5", 14 | "symfony/console": "7.3.*", 15 | "symfony/dotenv": "7.3.*", 16 | "symfony/flex": "^2", 17 | "symfony/framework-bundle": "7.3.*", 18 | "symfony/runtime": "7.3.*", 19 | "symfony/uid": "7.3.*", 20 | "symfony/validator": "7.3.*", 21 | "symfony/yaml": "7.3.*" 22 | }, 23 | "config": { 24 | "allow-plugins": { 25 | "php-http/discovery": true, 26 | "symfony/flex": true, 27 | "symfony/runtime": true 28 | }, 29 | "bump-after-update": true, 30 | "sort-packages": true 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "App\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "App\\Tests\\": "tests/" 40 | } 41 | }, 42 | "replace": { 43 | "symfony/polyfill-ctype": "*", 44 | "symfony/polyfill-iconv": "*", 45 | "symfony/polyfill-php72": "*", 46 | "symfony/polyfill-php73": "*", 47 | "symfony/polyfill-php74": "*", 48 | "symfony/polyfill-php80": "*", 49 | "symfony/polyfill-php81": "*", 50 | "symfony/polyfill-php82": "*" 51 | }, 52 | "scripts": { 53 | "auto-scripts": { 54 | "cache:clear": "symfony-cmd", 55 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 56 | }, 57 | "post-install-cmd": [ 58 | "@auto-scripts" 59 | ], 60 | "post-update-cmd": [ 61 | "@auto-scripts" 62 | ] 63 | }, 64 | "conflict": { 65 | "symfony/symfony": "*" 66 | }, 67 | "extra": { 68 | "symfony": { 69 | "allow-contrib": false, 70 | "require": "7.3.*" 71 | } 72 | }, 73 | "require-dev": { 74 | "deptrac/deptrac": "^3.0", 75 | "phpunit/phpunit": "^11.5", 76 | "qossmic/deptrac": "*", 77 | "symfony/browser-kit": "7.3.*", 78 | "symfony/css-selector": "7.3.*", 79 | "symfony/maker-bundle": "*" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 6 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 7 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 8 | ]; 9 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(resolve:DATABASE_URL)%' 4 | 5 | # IMPORTANT: You MUST configure your server version, 6 | # either here or in the DATABASE_URL env var (see .env file) 7 | #server_version: '16' 8 | 9 | profiling_collect_backtrace: '%kernel.debug%' 10 | use_savepoints: true 11 | orm: 12 | auto_generate_proxy_classes: true 13 | enable_lazy_ghost_objects: true 14 | report_fields_where_declared: true 15 | validate_xml_mapping: true 16 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 17 | identity_generation_preferences: 18 | Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity 19 | auto_mapping: false 20 | mappings: 21 | Catalogue: 22 | type: xml 23 | is_bundle: false 24 | dir: '%kernel.project_dir%/src/Catalogue/Infrastructure/Persistence/Doctrine/Mapping' 25 | prefix: 'App\Catalogue\Domain\Entity' 26 | alias: Catalogue 27 | Order: 28 | type: xml 29 | is_bundle: false 30 | dir: '%kernel.project_dir%/src/Order/Infrastructure/Persistence/Doctrine/Mapping' 31 | prefix: 'App\Order\Domain\Entity' 32 | alias: Order 33 | controller_resolver: 34 | auto_mapping: false 35 | 36 | when@test: 37 | doctrine: 38 | dbal: 39 | # "TEST_TOKEN" is typically set by ParaTest 40 | dbname_suffix: '_test%env(default::TEST_TOKEN)%' 41 | 42 | when@prod: 43 | doctrine: 44 | orm: 45 | auto_generate_proxy_classes: false 46 | proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' 47 | query_cache_driver: 48 | type: pool 49 | pool: doctrine.system_cache_pool 50 | result_cache_driver: 51 | type: pool 52 | pool: doctrine.result_cache_pool 53 | 54 | framework: 55 | cache: 56 | pools: 57 | doctrine.result_cache_pool: 58 | adapter: cache.app 59 | doctrine.system_cache_pool: 60 | adapter: cache.system 61 | -------------------------------------------------------------------------------- /config/packages/doctrine_migrations.yaml: -------------------------------------------------------------------------------- 1 | doctrine_migrations: 2 | migrations_paths: 3 | # namespace is arbitrary but should be different from App\Migrations 4 | # as migrations classes should NOT be autoloaded 5 | 'DoctrineMigrations': '%kernel.project_dir%/migrations' 6 | enable_profiler: false 7 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | 5 | # Note that the session will be started ONLY if you read or write from it. 6 | session: true 7 | 8 | #esi: true 9 | #fragments: true 10 | 11 | when@test: 12 | framework: 13 | test: true 14 | session: 15 | storage_factory_id: session.storage.factory.mock_file 16 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. 4 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands 5 | #default_uri: http://localhost 6 | 7 | when@prod: 8 | framework: 9 | router: 10 | strict_requirements: null 11 | -------------------------------------------------------------------------------- /config/packages/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | # Enables validator auto-mapping support. 4 | # For instance, basic validation constraints will be inferred from Doctrine's metadata. 5 | #auto_mapping: 6 | # App\Entity\: [] 7 | 8 | when@test: 9 | framework: 10 | validation: 11 | not_compromised_password: false 12 | -------------------------------------------------------------------------------- /config/preload.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE order_items (id INT AUTO_INCREMENT NOT NULL, order_id VARCHAR(255) NOT NULL, product_id VARCHAR(64) NOT NULL, quantity INT NOT NULL, price INT NOT NULL, INDEX IDX_62809DB08D9F6D38 (order_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 24 | $this->addSql('CREATE TABLE orders (id VARCHAR(255) NOT NULL, amount_to_pay INT NOT NULL, status VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL, fulfilled_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 25 | $this->addSql('CREATE TABLE products (id VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, price INT UNSIGNED NOT NULL, on_hand INT UNSIGNED NOT NULL, on_hold INT UNSIGNED NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 26 | $this->addSql('ALTER TABLE order_items ADD CONSTRAINT FK_62809DB08D9F6D38 FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE'); 27 | } 28 | 29 | public function down(Schema $schema): void 30 | { 31 | // this down() migration is auto-generated, please modify it to your needs 32 | $this->addSql('ALTER TABLE order_items DROP FOREIGN KEY FK_62809DB08D9F6D38'); 33 | $this->addSql('DROP TABLE order_items'); 34 | $this->addSql('DROP TABLE orders'); 35 | $this->addSql('DROP TABLE products'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /migrations/Version20250824124219.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE orders CHANGE fulfilled_at fulfilled_at DATETIME DEFAULT NULL'); 24 | } 25 | 26 | public function down(Schema $schema): void 27 | { 28 | // this down() migration is auto-generated, please modify it to your needs 29 | $this->addSql('ALTER TABLE orders CHANGE fulfilled_at fulfilled_at DATETIME NOT NULL'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /migrations/Version20250830094856.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE order_items ADD name VARCHAR(255) NOT NULL'); 24 | } 25 | 26 | public function down(Schema $schema): void 27 | { 28 | // this down() migration is auto-generated, please modify it to your needs 29 | $this->addSql('ALTER TABLE order_items DROP name'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /phpunit.dist.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | tests 23 | 24 | 25 | 26 | 31 | 32 | src 33 | 34 | 35 | 36 | Doctrine\Deprecations\Deprecation::trigger 37 | Doctrine\Deprecations\Deprecation::delegateTriggerToBackend 38 | trigger_deprecation 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | commandValidator->assert($command); 24 | return $this->transactionRunner->run(function () use ($command) { 25 | $product = Product::create($command->id, $command->name, Money::fromMinor($command->price), $command->onHand); 26 | $this->productRepository->add($product); 27 | 28 | return $product; 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Catalogue/Application/Command/Handler/FulfillStockReservationCommandHandler.php: -------------------------------------------------------------------------------- 1 | commandValidator->assert($command); 22 | $this->transactionRunner->run(function () use ($command) { 23 | foreach ($command->items as $item) { 24 | $product = $this->productRepository->get($item['product_id']); 25 | if (!$product) { 26 | throw new \DomainException('Product not found:' . $item['product_id']); 27 | } 28 | $product->commitReservation($item['quantity']); 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Catalogue/Application/Command/Handler/ReserveStockCommandHandler.php: -------------------------------------------------------------------------------- 1 | commandValidator->assert($command); 22 | $this->transactionRunner->run(function () use ($command) { 23 | foreach ($command->items as $item) { 24 | $product = $this->productRepository->get($item['product_id']); 25 | if (!$product) { 26 | throw new \DomainException('Unknown products'); 27 | } 28 | $product->hold($item['quantity']); 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Catalogue/Application/Command/ReserveStockCommand.php: -------------------------------------------------------------------------------- 1 | productRepository->getCollection($query->onlyAvailable, $query->maxPrice); 18 | if (!empty($collection)) { 19 | foreach ($collection as $product) { 20 | $result[] = [ 21 | 'id' => $product->getId(), 22 | 'name' => $product->getName(), 23 | 'price' => $product->getPrice()->toMajorString(), 24 | 'quantity' => $product->getAvailable() 25 | ]; 26 | } 27 | } 28 | 29 | return $result; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Catalogue/Contracts/Reservation/CatalogueFulfillReservationRequest.php: -------------------------------------------------------------------------------- 1 | id = $id; 27 | $self->name = $name; 28 | $self->price = $price->toMinor(); 29 | $self->onHand = $onHand; 30 | return $self; 31 | } 32 | 33 | public function hold(int $qty): void 34 | { 35 | if ($qty <= 0) { 36 | throw new NonPositiveQuantityException('Quantity must be > 0.'); 37 | } 38 | if ($qty > $this->getAvailable()) { 39 | throw new InsufficientStockException('Not enough stock available to hold.'); 40 | } 41 | $this->onHold += $qty; 42 | } 43 | 44 | public function commitReservation(int $qty): void 45 | { 46 | if ($qty <= 0) { 47 | throw new NonPositiveQuantityException('Quantity must be > 0.'); 48 | } 49 | if ($qty > $this->onHold) { 50 | throw new OverCommitReservationException('Cannot commit more than reserved.'); 51 | } 52 | 53 | $this->onHold -= $qty; 54 | $this->onHand -= $qty; 55 | } 56 | 57 | public function getName(): string 58 | { 59 | return $this->name; 60 | } 61 | 62 | public function getPrice(): Money 63 | { 64 | return Money::fromMinor($this->price); 65 | } 66 | 67 | public function getId(): string 68 | { 69 | return $this->id; 70 | } 71 | 72 | public function getOnHand(): int 73 | { 74 | return $this->onHand; 75 | } 76 | 77 | public function getOnHold(): int 78 | { 79 | return $this->onHold; 80 | } 81 | 82 | 83 | public function getAvailable(): int 84 | { 85 | return $this->onHand - $this->onHold; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Catalogue/Domain/Exception/EmptyProductNameException.php: -------------------------------------------------------------------------------- 1 | handler)(new FulfillStockReservationCommand($request->items)); 21 | return FulfillReservationResult::ok(); 22 | } catch (\Throwable $e) { 23 | return FulfillReservationResult::fail($e->getMessage()); 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/Catalogue/Infrastructure/Ohs/CatalogueStockReservationService.php: -------------------------------------------------------------------------------- 1 | items); 21 | ($this->handler)($command); 22 | return CatalogueReservationResult::ok(); 23 | } catch (\Throwable $e) { 24 | return CatalogueReservationResult::fail($e->getMessage()); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Catalogue/Infrastructure/Persistence/Doctrine/Mapping/Product.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Catalogue/Infrastructure/Persistence/Doctrine/Repository/ProductRepository.php: -------------------------------------------------------------------------------- 1 | entityManager->getRepository(Product::class)->findOneBy(['id' => $productId]); 20 | } 21 | 22 | public function getCollection(bool $onlyAvailable, ?int $maxPrice): array 23 | { 24 | $qb = $this->entityManager->createQueryBuilder()->select("p")->from(Product::class, "p"); 25 | if ($onlyAvailable) { 26 | $qb->andWhere("p.on_hand - p.on_hold > 0"); 27 | } 28 | if ($maxPrice) { 29 | $qb->andWhere("p.price <= :maxPrice")->setParameter("maxPrice", $maxPrice); 30 | } 31 | 32 | return $qb->getQuery()->getResult(); 33 | } 34 | 35 | public function add(Product $product): void 36 | { 37 | $this->entityManager->persist($product); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/Catalogue/Infrastructure/Validation/SymfonyCommandValidator.php: -------------------------------------------------------------------------------- 1 | validator->validate($command, null, $groups); 18 | if (count($viol) > 0) { 19 | throw new ValidationFailedException($command, $viol); 20 | } 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/Catalogue/Presentation/Http/Controller/ProductController.php: -------------------------------------------------------------------------------- 1 | get('only_available', false), $request->get('max_price')))); 23 | return new JsonResponse($envelope->body, $envelope->status); 24 | } 25 | 26 | #[Route('', name: 'products.create', methods: ['POST'])] 27 | public function create(CreateProductCommandHandler $handler, Request $request): JsonResponse 28 | { 29 | $command = new CreateProductCommand( 30 | Uuid::v7()->toString(), $request->get('name', ''), (int)$request->get('price', 0), (int)$request->get('on_hand', 0) 31 | ); 32 | $product = $handler($command); 33 | $envelope = ResponseEnvelope::success(['id' => $product->getId()], JsonResponse::HTTP_CREATED); 34 | 35 | return new JsonResponse($envelope->body, $envelope->status); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | commandValidator->assert($command); 28 | $order = $this->createOrder($command); 29 | $this->reserveProducts($order); 30 | 31 | return $order; 32 | } 33 | 34 | private function createOrder(CreateOrderCommand $command): Order 35 | { 36 | return $this->transactionRunner->run(function () use ($command) { 37 | $lines = array_map( 38 | fn(array $p) => new OrderLine((string)$p['product_id'], (string)$p['name'], (int)$p['quantity'], Money::fromMinor($p['price'])), 39 | $command->products 40 | ); 41 | $order = Order::create($command->id, Money::fromMinor($command->amountToPay), ...$lines); 42 | 43 | $this->orderRepository->add($order); 44 | 45 | return $order; 46 | }); 47 | } 48 | 49 | private function reserveProducts(Order $order): void 50 | { 51 | $items = []; 52 | foreach ($order->getItems() as $i) { 53 | $items[] = ['product_id' => (string)$i->getProductId(), 'quantity' => $i->getQuantity()]; 54 | } 55 | $reservationResult = $this->reservation->reserve(new ReservationRequest($order->getId(), $items)); 56 | $this->transactionRunner->run(function () use ($order, $reservationResult) { 57 | if ($reservationResult->success) { 58 | $order->setReserved(); 59 | } else { 60 | $order->setFailed(); 61 | } 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Order/Application/Command/Handler/FulfillOrderCommandHandler.php: -------------------------------------------------------------------------------- 1 | orderRepository->get($orderId); 22 | if (!$order) { 23 | throw new \DomainException("Order not found"); 24 | } 25 | $items = []; 26 | foreach ($order->getItems() as $i) { 27 | $items[] = ['product_id' => (string)$i->getProductId(), 'quantity' => $i->getQuantity()]; 28 | } 29 | $stockFulfillmentResult = $this->stock->fulfill(new FulfillReservationRequest($orderId, $items)); 30 | $this->transactionRunner->run(function () use ($order, $stockFulfillmentResult) { 31 | if ($stockFulfillmentResult->success) { 32 | $order->fulfill(); 33 | } else { 34 | $order->setFailed(); 35 | } 36 | }); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/Order/Application/Port/Dto/FulfillReservationRequest.php: -------------------------------------------------------------------------------- 1 | status) { 21 | $status = OrderStatus::tryFrom($query->status); 22 | } 23 | $collection = $this->orderRepository->getCollection($status); 24 | if (!empty($collection)) { 25 | foreach ($collection as $order) { 26 | $result[] = [ 27 | 'id' => $order->getId(), 28 | 'status' => $order->getStatus(), 29 | 'items' => $this->getItems($order) 30 | ]; 31 | } 32 | } 33 | return $result; 34 | } 35 | 36 | private function getItems(Order $order): array 37 | { 38 | $items = []; 39 | foreach ($order->getItems() as $item) { 40 | $items[] = [ 41 | 'id' => $item->getId(), 42 | 'name' => $item->getName(), 43 | 'quantity' => $item->getQuantity(), 44 | 'price' => $item->getPrice()->toMajorString() 45 | ]; 46 | } 47 | 48 | return $items; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Order/Domain/Entity/Order.php: -------------------------------------------------------------------------------- 1 | toMinor(); 24 | if ($amountToPayValue <= 0) { 25 | throw new NonPositiveOrderAmountException("Order amount must be greater than zero."); 26 | } 27 | 28 | $order = new self(); 29 | $order->id = $id; 30 | $order->amountToPay = $amountToPayValue; 31 | $order->status = OrderStatus::PENDING->value; 32 | $order->createdAt = new \DateTime(); 33 | 34 | foreach ($lines as $l) { 35 | $order->items[] = OrderItem::create($order, $l->productId(), $l->getName(), $l->quantity(), $l->price()->toMinor()); 36 | } 37 | 38 | return $order; 39 | } 40 | 41 | public function fulfill(): void 42 | { 43 | if ($this->isFulfilled()) { 44 | throw new InvalidOrderStateTransitionException("Order already fulfilled"); 45 | } 46 | if (!$this->isReserved()) { 47 | throw new OrderItemsNotReservedException("Order items not reserved"); 48 | } 49 | $this->status = OrderStatus::FULFILLED->value; 50 | $this->fulfilledAt = new \DateTime(); 51 | } 52 | 53 | public function getId(): string 54 | { 55 | return $this->id; 56 | } 57 | 58 | public function getAmountToPay(): int 59 | { 60 | return $this->amountToPay; 61 | } 62 | 63 | public function getItems(): iterable 64 | { 65 | return $this->items; 66 | } 67 | 68 | public function setReserved(): void 69 | { 70 | $this->status = OrderStatus::RESERVED->value; 71 | } 72 | 73 | public function setFailed(): void 74 | { 75 | $this->status = OrderStatus::FAILED->value; 76 | } 77 | 78 | 79 | public function isFulfilled(): bool 80 | { 81 | return $this->status === OrderStatus::FULFILLED->value; 82 | } 83 | 84 | public function isReserved(): bool 85 | { 86 | return $this->status === OrderStatus::RESERVED->value; 87 | } 88 | 89 | public function getStatus(): string 90 | { 91 | return $this->status; 92 | } 93 | 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/Order/Domain/Entity/OrderItem.php: -------------------------------------------------------------------------------- 1 | order = $order; 20 | $orderItem->productId = $productId; 21 | $orderItem->name = $name; 22 | $orderItem->quantity = $quantity; 23 | $orderItem->price = $price; 24 | 25 | return $orderItem; 26 | } 27 | 28 | public function getId(): int 29 | { 30 | return $this->id; 31 | } 32 | 33 | public function getName(): string 34 | { 35 | return $this->name; 36 | } 37 | 38 | public function getQuantity(): int 39 | { 40 | return $this->quantity; 41 | } 42 | 43 | public function getProductId(): string 44 | { 45 | return $this->productId; 46 | } 47 | 48 | public function getPrice(): Money 49 | { 50 | return Money::fromMinor($this->price); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Order/Domain/Enum/OrderStatus.php: -------------------------------------------------------------------------------- 1 | 0 required'); 17 | } 18 | if ($quantity <= 0) { 19 | throw new \LogicException('Price > 0 required'); 20 | } 21 | } 22 | 23 | public function productId(): string 24 | { 25 | return $this->productId; 26 | } 27 | 28 | public function getName(): string 29 | { 30 | return $this->name; 31 | } 32 | 33 | public function quantity(): int 34 | { 35 | return $this->quantity; 36 | } 37 | 38 | public function price(): Money 39 | { 40 | return $this->price; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Order/Infrastructure/Persistence/Doctrine/Mapping/Order.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Order/Infrastructure/Persistence/Doctrine/Mapping/OrderItem.orm.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Order/Infrastructure/Persistence/Doctrine/Repository/OrderRepository.php: -------------------------------------------------------------------------------- 1 | entityManager->getRepository(Order::class)->findOneBy(['id' => $orderId]); 21 | } 22 | 23 | public function getCollection(?OrderStatus $status): array 24 | { 25 | $qb = $this->entityManager->createQueryBuilder() 26 | ->select('o, i')->from(Order::class, 'o')->leftJoin('o.items', 'i'); 27 | 28 | if ($status !== null) { 29 | $qb->andWhere('o.status = :status')->setParameter('status', $status->value); 30 | } 31 | 32 | return $qb->getQuery()->getResult(); 33 | } 34 | 35 | public function add(Order $order): void 36 | { 37 | $this->entityManager->persist($order); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/Order/Infrastructure/Validation/SymfonyCommandValidator.php: -------------------------------------------------------------------------------- 1 | validator->validate($command, null, $groups); 18 | if (count($viol) > 0) { 19 | throw new ValidationFailedException($command, $viol); 20 | } 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/Order/Integration/Catalogue/StockReservationAdapter.php: -------------------------------------------------------------------------------- 1 | ['product_id' => $i['product_id'], 'quantity' => $i['quantity']], $request->items), 21 | ['order_id' => $request->orderId] 22 | ); 23 | $reservationResult = $this->reservation->reserve($catalogueRequest); 24 | return $reservationResult->success ? ReservationResult::ok() : ReservationResult::fail($reservationResult->reason); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Order/Integration/Catalogue/StockReservationFulfilmentAdapter.php: -------------------------------------------------------------------------------- 1 | ['product_id' => $i['product_id'], 'quantity' => $i['quantity']], $request->items), 22 | ['order_id' => $request->orderId] 23 | ); 24 | $fulfilmentResult = $this->reservationFulfillment->fulfill($catalogueRequest); 25 | return $fulfilmentResult->success ? ReservationFulfilmentResult::ok() : ReservationFulfilmentResult::fail($fulfilmentResult->reason); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/Order/Presentation/Http/Controller/OrderController.php: -------------------------------------------------------------------------------- 1 | get('status')))); 24 | 25 | return new JsonResponse($envelope->body, $envelope->status); 26 | } 27 | 28 | #[Route('', name: 'orders.create', methods: ['POST'])] 29 | public function create(CreateOrderCommandHandler $handler, Request $request): JsonResponse 30 | { 31 | $command = new CreateOrderCommand(Uuid::v7()->toString(), $request->get('amount_to_pay', 0), $request->get('products', [])); 32 | 33 | $order = $handler($command); 34 | $envelope = ResponseEnvelope::success(['id' => $order->getId()], JsonResponse::HTTP_CREATED); 35 | return new JsonResponse($envelope->body, $envelope->status); 36 | } 37 | 38 | #[Route('/{orderId}/fulfill', name: 'orders.fulfill', methods: ['POST'])] 39 | public function fulfill(FulfillOrderCommandHandler $handler, Request $request): JsonResponse 40 | { 41 | $handler($request->get('orderId')); 42 | $envelope = ResponseEnvelope::success(); 43 | return new JsonResponse($envelope->body, $envelope->status); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/SharedKernel/Domain/Persistence/TransactionRunnerInterface.php: -------------------------------------------------------------------------------- 1 | amountMinor = $amountMinor; 16 | } 17 | 18 | public static function fromMinor(int $amountMinor): self 19 | { 20 | return new self($amountMinor); 21 | } 22 | 23 | public function toMinor(): int 24 | { 25 | return $this->amountMinor; 26 | } 27 | 28 | public function toMajorString(): string 29 | { 30 | if (self::SCALE === 0) { 31 | return (string)$this->amountMinor; 32 | } 33 | $base = 10 ** self::SCALE; 34 | $int = intdiv($this->amountMinor, $base); 35 | $frac = $this->amountMinor % $base; 36 | return sprintf('%d.%0' . self::SCALE . 'd', $int, $frac); 37 | } 38 | 39 | public function equals(self $other): bool 40 | { 41 | return $this->amountMinor === $other->amountMinor; 42 | } 43 | 44 | public function __toString(): string 45 | { 46 | return $this->toMajorString(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/SharedKernel/Http/ResponseEnvelope.php: -------------------------------------------------------------------------------- 1 | $data]; 14 | if ($meta !== []) { 15 | $b['meta'] = $meta; 16 | } 17 | return new self($status, $b); 18 | } 19 | 20 | public static function error(int $status, string $message, string $type = 'error', ?string $traceId = null, array $details = []): self 21 | { 22 | $err = ['code' => $status, 'message' => $message, 'type' => $type]; 23 | if ($traceId) { 24 | $err['trace_id'] = $traceId; 25 | } 26 | if ($details !== []) { 27 | $err['details'] = $details; 28 | } 29 | return new self($status, ['error' => $err]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/SharedKernel/Infrastructure/Http/Exception/ExceptionListener.php: -------------------------------------------------------------------------------- 1 | getThrowable(); 18 | if ($e instanceof ValidationFailedException) { 19 | $event->setResponse($this->errorResponseFactory->validationError($e->getViolations())); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/SharedKernel/Infrastructure/Http/SymfonyErrorResponder.php: -------------------------------------------------------------------------------- 1 | body, $envelope->status); 15 | } 16 | 17 | public function validationError(ConstraintViolationListInterface $violations, ?string $traceId = null): JsonResponse 18 | { 19 | $errors = []; 20 | 21 | foreach ($violations as $violation) { 22 | $errors[] = [ 23 | 'field' => $violation->getPropertyPath(), 24 | 'message' => $violation->getMessage(), 25 | ]; 26 | } 27 | 28 | return $this->error(JsonResponse::HTTP_UNPROCESSABLE_ENTITY, 'Validation failed', 'validation_error', $traceId, ['errors' => $errors]); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/SharedKernel/Infrastructure/Persistence/Doctrine/DoctrineTransactionRunner.php: -------------------------------------------------------------------------------- 1 | entityManager->getConnection()->transactional(function () use ($callback) { 19 | $result = $callback(); 20 | $this->entityManager->flush(); 21 | return $result; 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /symfony.lock: -------------------------------------------------------------------------------- 1 | { 2 | "deptrac/deptrac": { 3 | "version": "3.0", 4 | "recipe": { 5 | "repo": "github.com/symfony/recipes-contrib", 6 | "branch": "main", 7 | "version": "3.0", 8 | "ref": "05ab8813714080c7bc915cb10c780e6cfdbdb991" 9 | } 10 | }, 11 | "doctrine/deprecations": { 12 | "version": "1.1", 13 | "recipe": { 14 | "repo": "github.com/symfony/recipes", 15 | "branch": "main", 16 | "version": "1.0", 17 | "ref": "87424683adc81d7dc305eefec1fced883084aab9" 18 | } 19 | }, 20 | "doctrine/doctrine-bundle": { 21 | "version": "2.15", 22 | "recipe": { 23 | "repo": "github.com/symfony/recipes", 24 | "branch": "main", 25 | "version": "2.13", 26 | "ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb" 27 | }, 28 | "files": [ 29 | "config/packages/doctrine.yaml", 30 | "src/Entity/.gitignore", 31 | "src/Repository/.gitignore" 32 | ] 33 | }, 34 | "doctrine/doctrine-migrations-bundle": { 35 | "version": "3.4", 36 | "recipe": { 37 | "repo": "github.com/symfony/recipes", 38 | "branch": "main", 39 | "version": "3.1", 40 | "ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33" 41 | }, 42 | "files": [ 43 | "config/packages/doctrine_migrations.yaml", 44 | "migrations/.gitignore" 45 | ] 46 | }, 47 | "phpunit/phpunit": { 48 | "version": "11.5", 49 | "recipe": { 50 | "repo": "github.com/symfony/recipes", 51 | "branch": "main", 52 | "version": "11.1", 53 | "ref": "c6658a60fc9d594805370eacdf542c3d6b5c0869" 54 | }, 55 | "files": [ 56 | ".env.test", 57 | "phpunit.dist.xml", 58 | "tests/bootstrap.php", 59 | "bin/phpunit" 60 | ] 61 | }, 62 | "qossmic/deptrac": { 63 | "version": "2.0", 64 | "recipe": { 65 | "repo": "github.com/symfony/recipes-contrib", 66 | "branch": "main", 67 | "version": "2.0", 68 | "ref": "05ab8813714080c7bc915cb10c780e6cfdbdb991" 69 | } 70 | }, 71 | "symfony/console": { 72 | "version": "7.3", 73 | "recipe": { 74 | "repo": "github.com/symfony/recipes", 75 | "branch": "main", 76 | "version": "5.3", 77 | "ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461" 78 | }, 79 | "files": [ 80 | "bin/console" 81 | ] 82 | }, 83 | "symfony/flex": { 84 | "version": "2.8", 85 | "recipe": { 86 | "repo": "github.com/symfony/recipes", 87 | "branch": "main", 88 | "version": "2.4", 89 | "ref": "52e9754527a15e2b79d9a610f98185a1fe46622a" 90 | }, 91 | "files": [ 92 | ".env", 93 | ".env.dev" 94 | ] 95 | }, 96 | "symfony/framework-bundle": { 97 | "version": "7.3", 98 | "recipe": { 99 | "repo": "github.com/symfony/recipes", 100 | "branch": "main", 101 | "version": "7.3", 102 | "ref": "5a1497d539f691b96afd45ae397ce5fe30beb4b9" 103 | }, 104 | "files": [ 105 | "config/packages/cache.yaml", 106 | "config/packages/framework.yaml", 107 | "config/preload.php", 108 | "config/routes/framework.yaml", 109 | "config/services.yaml", 110 | "public/index.php", 111 | "src/Controller/.gitignore", 112 | "src/Kernel.php", 113 | ".editorconfig" 114 | ] 115 | }, 116 | "symfony/maker-bundle": { 117 | "version": "1.64", 118 | "recipe": { 119 | "repo": "github.com/symfony/recipes", 120 | "branch": "main", 121 | "version": "1.0", 122 | "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" 123 | } 124 | }, 125 | "symfony/routing": { 126 | "version": "7.3", 127 | "recipe": { 128 | "repo": "github.com/symfony/recipes", 129 | "branch": "main", 130 | "version": "7.0", 131 | "ref": "21b72649d5622d8f7da329ffb5afb232a023619d" 132 | }, 133 | "files": [ 134 | "config/packages/routing.yaml", 135 | "config/routes.yaml" 136 | ] 137 | }, 138 | "symfony/uid": { 139 | "version": "7.3", 140 | "recipe": { 141 | "repo": "github.com/symfony/recipes", 142 | "branch": "main", 143 | "version": "7.0", 144 | "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" 145 | } 146 | }, 147 | "symfony/validator": { 148 | "version": "7.3", 149 | "recipe": { 150 | "repo": "github.com/symfony/recipes", 151 | "branch": "main", 152 | "version": "7.0", 153 | "ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd" 154 | }, 155 | "files": [ 156 | "config/packages/validator.yaml" 157 | ] 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /tests/Application/Command/CreateProductCommandHandlerTest.php: -------------------------------------------------------------------------------- 1 | createMock(CommandValidatorInterface::class); 22 | $cmd = new CreateProductCommand('p1', 'Apple', 199, 10); 23 | $handler = new CreateProductCommandHandler($repo, $validator, $transactionRunner); 24 | 25 | $product = $handler($cmd); 26 | 27 | self::assertInstanceOf(Product::class, $product); 28 | self::assertSame($cmd->id, $product->getId()); 29 | self::assertSame($cmd->name, $product->getName()); 30 | self::assertSame($cmd->onHand, $product->getOnHand()); 31 | self::assertSame($cmd->price, $product->getPrice()->toMinor()); 32 | self::assertTrue($transactionRunner->committed); 33 | } 34 | 35 | public function test_throws_domain_exception_on_invalid_name(): void 36 | { 37 | $repo = new InMemoryProductRepository(); 38 | $transactionRunner = new InMemoryTransactionRunner(); 39 | 40 | $validator = $this->createMock(CommandValidatorInterface::class); 41 | $cmd = new CreateProductCommand('p2', '', 100, 5); 42 | $handler = new CreateProductCommandHandler($repo, $validator, $transactionRunner); 43 | 44 | $this->expectException(EmptyProductNameException::class); 45 | $handler($cmd); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Application/Command/FulfillStockReservationCommandHandlerTest.php: -------------------------------------------------------------------------------- 1 | hold(2); 22 | $p2->hold(1); 23 | 24 | $repo = new InMemoryProductRepository(['p1' => $p1, 'p2' => $p2]); 25 | $transactionRunner = new InMemoryTransactionRunner(); 26 | 27 | $validator = $this->createMock(CommandValidatorInterface::class); 28 | $handler = new FulfillStockReservationCommandHandler($repo, $validator, $transactionRunner); 29 | 30 | ($handler)(new FulfillStockReservationCommand([ 31 | ['product_id' => 'p1', 'quantity' => 2], 32 | ['product_id' => 'p2', 'quantity' => 1], 33 | ])); 34 | 35 | self::assertSame(0, $repo->get('p1')->getOnHold()); 36 | self::assertSame(3, $repo->get('p1')->getOnHand()); 37 | 38 | self::assertSame(0, $repo->get('p2')->getOnHold()); 39 | self::assertSame(2, $repo->get('p2')->getOnHand()); 40 | } 41 | 42 | public function test_throws_on_unknown_product(): void 43 | { 44 | $repo = new InMemoryProductRepository([]); 45 | $transactionRunner = new InMemoryTransactionRunner(); 46 | 47 | $validator = $this->createMock(CommandValidatorInterface::class); 48 | $handler = new FulfillStockReservationCommandHandler($repo, $validator, $transactionRunner); 49 | 50 | $this->expectException(\DomainException::class); 51 | ($handler)(new FulfillStockReservationCommand([ 52 | ['product_id' => 'unknown', 'quantity' => 1], 53 | ])); 54 | } 55 | 56 | public function test_throws_when_committing_more_than_reserved(): void 57 | { 58 | $p1 = Product::create('p1', 'A', Money::fromMinor(100), 5); 59 | $p1->hold(1); 60 | 61 | $repo = new InMemoryProductRepository(['p1' => $p1]); 62 | $transactionRunner = new InMemoryTransactionRunner(); 63 | 64 | $validator = $this->createMock(CommandValidatorInterface::class); 65 | $handler = new FulfillStockReservationCommandHandler($repo, $validator, $transactionRunner); 66 | 67 | $this->expectException(\DomainException::class); 68 | ($handler)(new FulfillStockReservationCommand([ 69 | ['product_id' => 'p1', 'quantity' => 2], 70 | ])); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/Application/Command/ReserveStockCommandHandlerTest.php: -------------------------------------------------------------------------------- 1 | Product::create('p1', 'A', Money::fromMinor(100), 5), 20 | 'p2' => Product::create('p2', 'B', Money::fromMinor(200), 3), 21 | ]); 22 | $transactionRunner = new InMemoryTransactionRunner(); 23 | 24 | $validator = $this->createMock(CommandValidatorInterface::class); 25 | $handler = new ReserveStockCommandHandler($repo, $validator, $transactionRunner); 26 | 27 | ($handler)(new ReserveStockCommand([ 28 | ['product_id' => 'p1', 'quantity' => 2], 29 | ['product_id' => 'p2', 'quantity' => 1], 30 | ])); 31 | 32 | self::assertSame(2, $repo->get('p1')->getOnHold()); 33 | self::assertSame(1, $repo->get('p2')->getOnHold()); 34 | } 35 | 36 | public function test_throws_on_unknown_product(): void 37 | { 38 | $repo = new InMemoryProductRepository([]); 39 | $tx = new InMemoryTransactionRunner(); 40 | 41 | $validator = $this->createMock(CommandValidatorInterface::class); 42 | $handler = new ReserveStockCommandHandler($repo, $validator, $tx); 43 | 44 | $this->expectException(\DomainException::class); 45 | ($handler)(new ReserveStockCommand([ 46 | ['product_id' => 'unknown', 'quantity' => 1], 47 | ])); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Application/Query/GetProductsQueryHandlerTest.php: -------------------------------------------------------------------------------- 1 | $product) { 29 | self::assertSame($product->getId(), $result[$i]['id']); 30 | self::assertSame($product->getName(), $result[$i]['name']); 31 | self::assertSame($product->getPrice()->toMajorString(), $result[$i]['price']); 32 | self::assertSame($product->getAvailable(), $result[$i]['quantity']); 33 | } 34 | } 35 | 36 | public function test_returns_empty_array_when_no_products(): void 37 | { 38 | $repo = new InMemoryProductRepository([]); 39 | $handler = new GetProductsQueryHandler($repo); 40 | 41 | $result = ($handler)(new GetProductsQuery(false, null)); 42 | 43 | self::assertSame([], $result); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Domain/OrderItemTest.php: -------------------------------------------------------------------------------- 1 | getItems()]; 22 | self::assertCount(1, $items); 23 | 24 | $item = $items[0]; 25 | self::assertSame('p1', $item->getProductId()); 26 | self::assertSame(1, $item->getQuantity()); 27 | self::assertTrue(Money::fromMinor(1000)->equals($item->getPrice())); 28 | 29 | $direct = OrderItem::create($order, 'p2', 'Product 2', 2, 500); 30 | self::assertSame('p2', $direct->getProductId()); 31 | self::assertSame(2, $direct->getQuantity()); 32 | self::assertTrue(Money::fromMinor(500)->equals($direct->getPrice())); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Domain/OrderTest.php: -------------------------------------------------------------------------------- 1 | $this->line(...$l), $lines), 26 | ); 27 | 28 | self::assertSame('ord-1', $order->getId()); 29 | self::assertSame(1500, $order->getAmountToPay()); 30 | 31 | $items = [...$order->getItems()]; 32 | self::assertCount(count($lines), $items); 33 | 34 | foreach ($lines as $i => [$pid, , $qty, $price]) { 35 | $item = $items[$i]; 36 | self::assertSame($pid, $item->getProductId()); 37 | self::assertSame($qty, $item->getQuantity()); 38 | self::assertTrue(Money::fromMinor($price)->equals($item->getPrice())); 39 | } 40 | } 41 | 42 | public function test_create_rejects_non_positive_amount(): void 43 | { 44 | $this->expectException(NonPositiveOrderAmountException::class); 45 | Order::create('ord-2', Money::fromMinor(0)); 46 | } 47 | 48 | public function test_fulfill_happy_path_requires_reserved(): void 49 | { 50 | $order = Order::create('ord-3', Money::fromMinor(500), $this->line('p1', 'Product 1', 1, 500)); 51 | $order->setReserved(); 52 | 53 | $order->fulfill(); 54 | 55 | self::assertTrue($order->isFulfilled()); 56 | } 57 | 58 | public function test_fulfill_throws_when_not_reserved(): void 59 | { 60 | $order = Order::create('ord-4', Money::fromMinor(500), $this->line('p1', 'Product 1', 1, 500)); 61 | 62 | $this->expectException(OrderItemsNotReservedException::class); 63 | $order->fulfill(); 64 | } 65 | 66 | public function test_fulfill_throws_when_already_fulfilled(): void 67 | { 68 | $order = Order::create('ord-5', Money::fromMinor(500), $this->line('p1', 'Product 1', 1, 500)); 69 | $order->setReserved(); 70 | $order->fulfill(); 71 | 72 | $this->expectException(InvalidOrderStateTransitionException::class); 73 | $order->fulfill(); 74 | } 75 | 76 | public function test_set_failed_sets_status_failed(): void 77 | { 78 | $order = Order::create('ord-6', Money::fromMinor(500), $this->line('p1', 'Product 1', 1, 500)); 79 | $order->setFailed(); 80 | 81 | self::assertFalse($order->isReserved()); 82 | self::assertFalse($order->isFulfilled()); 83 | } 84 | 85 | private function line(string $pid, string $name, int $qty, int $price): OrderLine 86 | { 87 | return new OrderLine($pid, $name, $qty, Money::fromMinor($price)); 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /tests/Domain/ProductTest.php: -------------------------------------------------------------------------------- 1 | getId()); 20 | self::assertSame('Apple', $p->getName()); 21 | self::assertSame(199, $p->getPrice()->toMinor()); 22 | self::assertSame(10, $p->getOnHand()); 23 | self::assertSame(0, $p->getOnHold()); 24 | self::assertSame(10, $p->getAvailable()); 25 | } 26 | 27 | public function test_create_rejects_empty_name(): void 28 | { 29 | $this->expectException(EmptyProductNameException::class); 30 | Product::create('p1', '', Money::fromMinor(100), 5); 31 | } 32 | 33 | public function test_hold_increases_on_hold_and_reduces_available(): void 34 | { 35 | $p = Product::create('p1', 'A', Money::fromMinor(100), 5); 36 | 37 | $p->hold(3); 38 | 39 | self::assertSame(5, $p->getOnHand()); 40 | self::assertSame(3, $p->getOnHold()); 41 | self::assertSame(2, $p->getAvailable()); 42 | } 43 | 44 | public function test_hold_rejects_non_positive_quantity(): void 45 | { 46 | $p = Product::create('p1', 'A', Money::fromMinor(100), 5); 47 | 48 | $this->expectException(NonPositiveQuantityException::class); 49 | $p->hold(0); 50 | } 51 | 52 | public function test_hold_rejects_when_insufficient_available(): void 53 | { 54 | $p = Product::create('p1', 'A', Money::fromMinor(100), 5); 55 | $p->hold(4); 56 | 57 | $this->expectException(InsufficientStockException::class); 58 | $p->hold(2); 59 | } 60 | 61 | public function test_commit_reservation_happy_path(): void 62 | { 63 | $p = Product::create('p1', 'A', Money::fromMinor(100), 5); 64 | $p->hold(3); 65 | 66 | $p->commitReservation(2); 67 | 68 | self::assertSame(3, $p->getOnHand()); 69 | self::assertSame(1, $p->getOnHold()); 70 | self::assertSame(2, $p->getAvailable()); 71 | } 72 | 73 | public function test_commit_rejects_non_positive_quantity(): void 74 | { 75 | $p = Product::create('p1', 'A', Money::fromMinor(100), 5); 76 | 77 | $this->expectException(NonPositiveQuantityException::class); 78 | $p->commitReservation(0); 79 | } 80 | 81 | public function test_commit_cannot_exceed_on_hold(): void 82 | { 83 | $p = Product::create('p1', 'A', Money::fromMinor(100), 5); 84 | $p->hold(2); 85 | 86 | $this->expectException(OverCommitReservationException::class); 87 | $p->commitReservation(3); 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /tests/Support/InMemoryProductRepository.php: -------------------------------------------------------------------------------- 1 | products = $products; 15 | } 16 | 17 | public function add(Product $product): void 18 | { 19 | $this->products[] = ['add', $product->getId()]; 20 | } 21 | 22 | public function getCollection(bool $onlyAvailable, ?int $maxPrice): array 23 | { 24 | return $this->products; 25 | } 26 | 27 | public function get(string $productId): ?Product 28 | { 29 | foreach ($this->products as $product) { 30 | if ($product->getId() === $productId) { 31 | return $product; 32 | } 33 | } 34 | 35 | return null; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Support/InMemoryTransactionRunner.php: -------------------------------------------------------------------------------- 1 | committed = true; 15 | return $res; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 9 | } 10 | 11 | if ($_SERVER['APP_DEBUG']) { 12 | umask(0000); 13 | } 14 | --------------------------------------------------------------------------------