├── .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 | 
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 |
--------------------------------------------------------------------------------