├── .gitignore ├── README.md ├── docs └── image │ ├── bounded-context.png │ └── erd.png └── src ├── alembic.ini ├── display ├── __init__.py ├── application │ ├── __init__.py │ └── use_case │ │ ├── __init__.py │ │ └── query.py ├── domain │ ├── __init__.py │ └── entity │ │ ├── __init__.py │ │ └── room.py ├── infra │ ├── __init__.py │ ├── container.py │ ├── external_api │ │ └── __init__.py │ └── repository.py └── presentation │ ├── __init__.py │ ├── grpc │ └── __init__.py │ └── rest │ ├── __init__.py │ ├── api.py │ ├── request.py │ └── response.py ├── pytest.ini ├── reception ├── __init__.py ├── application │ ├── __init__.py │ └── use_case │ │ ├── __init__.py │ │ ├── command.py │ │ └── query.py ├── domain │ ├── __init__.py │ ├── entity │ │ ├── __init__.py │ │ ├── reservation.py │ │ └── room.py │ ├── exception │ │ ├── __init__.py │ │ ├── check_in.py │ │ ├── reservation.py │ │ └── room.py │ ├── service │ │ ├── __init__.py │ │ └── check_in.py │ └── value_object │ │ ├── __init__.py │ │ ├── guest.py │ │ └── reservation.py ├── infra │ ├── __init__.py │ ├── container.py │ ├── external_api │ │ └── __init__.py │ └── repository.py └── presentation │ ├── __init__.py │ ├── grpc │ └── __init__.py │ └── rest │ ├── __init__.py │ ├── api.py │ ├── request.py │ └── response.py ├── requirements.txt ├── shared_kernel ├── __init__.py ├── domain │ ├── __init__.py │ ├── entity.py │ ├── exception.py │ └── value_object.py ├── infra │ ├── __init__.py │ ├── container.py │ ├── database │ │ ├── __init__.py │ │ ├── connection.py │ │ ├── migrations │ │ │ ├── README │ │ │ ├── __init__.py │ │ │ ├── env.py │ │ │ ├── script.py.mako │ │ │ └── versions │ │ │ │ ├── 6b595c7689ad_init.py │ │ │ │ └── __init__.py │ │ ├── orm.py │ │ └── repository.py │ ├── fastapi │ │ ├── __init__.py │ │ ├── config.py │ │ └── main.py │ └── log │ │ └── __init__.py └── presentation │ ├── __init__.py │ └── response.py └── tests ├── __init__.py ├── conftest.py ├── functional ├── __init__.py ├── test_display.py └── test_reception.py ├── integration ├── __init__.py └── database │ ├── __init__.py │ ├── conftest.py │ ├── test_display.py │ └── test_reception.py └── unit └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all,visualstudiocode,macos,windows 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all,visualstudiocode,macos,windows 3 | 4 | ### macOS ### 5 | # General 6 | .DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | 10 | # Icon must end with two \r 11 | Icon 12 | 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### macOS Patch ### 34 | # iCloud generated files 35 | *.icloud 36 | 37 | ### PyCharm+all ### 38 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 39 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 40 | 41 | # User-specific stuff 42 | .idea/**/workspace.xml 43 | .idea/**/tasks.xml 44 | .idea/**/usage.statistics.xml 45 | .idea/**/dictionaries 46 | .idea/**/shelf 47 | 48 | # AWS User-specific 49 | .idea/**/aws.xml 50 | 51 | # Generated files 52 | .idea/**/contentModel.xml 53 | 54 | # Sensitive or high-churn files 55 | .idea/**/dataSources/ 56 | .idea/**/dataSources.ids 57 | .idea/**/dataSources.local.xml 58 | .idea/**/sqlDataSources.xml 59 | .idea/**/dynamic.xml 60 | .idea/**/uiDesigner.xml 61 | .idea/**/dbnavigator.xml 62 | 63 | # Gradle 64 | .idea/**/gradle.xml 65 | .idea/**/libraries 66 | 67 | # Gradle and Maven with auto-import 68 | # When using Gradle or Maven with auto-import, you should exclude module files, 69 | # since they will be recreated, and may cause churn. Uncomment if using 70 | # auto-import. 71 | # .idea/artifacts 72 | # .idea/compiler.xml 73 | # .idea/jarRepositories.xml 74 | # .idea/modules.xml 75 | # .idea/*.iml 76 | # .idea/modules 77 | # *.iml 78 | # *.ipr 79 | 80 | # CMake 81 | cmake-build-*/ 82 | 83 | # Mongo Explorer plugin 84 | .idea/**/mongoSettings.xml 85 | 86 | # File-based project format 87 | *.iws 88 | 89 | # IntelliJ 90 | out/ 91 | 92 | # mpeltonen/sbt-idea plugin 93 | .idea_modules/ 94 | 95 | # JIRA plugin 96 | atlassian-ide-plugin.xml 97 | 98 | # Cursive Clojure plugin 99 | .idea/replstate.xml 100 | 101 | # SonarLint plugin 102 | .idea/sonarlint/ 103 | 104 | # Crashlytics plugin (for Android Studio and IntelliJ) 105 | com_crashlytics_export_strings.xml 106 | crashlytics.properties 107 | crashlytics-build.properties 108 | fabric.properties 109 | 110 | # Editor-based Rest Client 111 | .idea/httpRequests 112 | 113 | # Android studio 3.1+ serialized cache file 114 | .idea/caches/build_file_checksums.ser 115 | 116 | ### PyCharm+all Patch ### 117 | # Ignore everything but code style settings and run configurations 118 | # that are supposed to be shared within teams. 119 | 120 | .idea/* 121 | 122 | !.idea/codeStyles 123 | !.idea/runConfigurations 124 | 125 | ### Python ### 126 | # Byte-compiled / optimized / DLL files 127 | __pycache__/ 128 | *.py[cod] 129 | *$py.class 130 | 131 | # C extensions 132 | *.so 133 | 134 | # Distribution / packaging 135 | .Python 136 | build/ 137 | develop-eggs/ 138 | dist/ 139 | downloads/ 140 | eggs/ 141 | .eggs/ 142 | lib/ 143 | lib64/ 144 | parts/ 145 | sdist/ 146 | var/ 147 | wheels/ 148 | share/python-wheels/ 149 | *.egg-info/ 150 | .installed.cfg 151 | *.egg 152 | MANIFEST 153 | 154 | # PyInstaller 155 | # Usually these files are written by a python script from a template 156 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 157 | *.manifest 158 | *.spec 159 | 160 | # Installer logs 161 | pip-log.txt 162 | pip-delete-this-directory.txt 163 | 164 | # Unit test / coverage reports 165 | htmlcov/ 166 | .tox/ 167 | .nox/ 168 | .coverage 169 | .coverage.* 170 | .cache 171 | nosetests.xml 172 | coverage.xml 173 | *.cover 174 | *.py,cover 175 | .hypothesis/ 176 | .pytest_cache/ 177 | cover/ 178 | 179 | # Translations 180 | *.mo 181 | *.pot 182 | 183 | # Django stuff: 184 | *.log 185 | local_settings.py 186 | db.sqlite3 187 | db.sqlite3-journal 188 | 189 | # Flask stuff: 190 | instance/ 191 | .webassets-cache 192 | 193 | # Scrapy stuff: 194 | .scrapy 195 | 196 | # Sphinx documentation 197 | docs/_build/ 198 | 199 | # PyBuilder 200 | .pybuilder/ 201 | target/ 202 | 203 | # Jupyter Notebook 204 | .ipynb_checkpoints 205 | 206 | # IPython 207 | profile_default/ 208 | ipython_config.py 209 | 210 | # pyenv 211 | # For a library or package, you might want to ignore these files since the code is 212 | # intended to run in multiple environments; otherwise, check them in: 213 | .python-version 214 | 215 | # pipenv 216 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 217 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 218 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 219 | # install all needed dependencies. 220 | #Pipfile.lock 221 | 222 | # poetry 223 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 224 | # This is especially recommended for binary packages to ensure reproducibility, and is more 225 | # commonly ignored for libraries. 226 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 227 | #poetry.lock 228 | 229 | # pdm 230 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 231 | #pdm.lock 232 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 233 | # in version control. 234 | # https://pdm.fming.dev/#use-with-ide 235 | .pdm.toml 236 | 237 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 238 | __pypackages__/ 239 | 240 | # Celery stuff 241 | celerybeat-schedule 242 | celerybeat.pid 243 | 244 | # SageMath parsed files 245 | *.sage.py 246 | 247 | # Environments 248 | .env 249 | .venv 250 | env/ 251 | venv/ 252 | ENV/ 253 | env.bak/ 254 | venv.bak/ 255 | 256 | # Spyder project settings 257 | .spyderproject 258 | .spyproject 259 | 260 | # Rope project settings 261 | .ropeproject 262 | 263 | # mkdocs documentation 264 | /site 265 | 266 | # mypy 267 | .mypy_cache/ 268 | .dmypy.json 269 | dmypy.json 270 | 271 | # Pyre type checker 272 | .pyre/ 273 | 274 | # pytype static type analyzer 275 | .pytype/ 276 | 277 | # Cython debug symbols 278 | cython_debug/ 279 | 280 | # PyCharm 281 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 282 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 283 | # and can be added to the global gitignore or merged into this file. For a more nuclear 284 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 285 | #.idea/ 286 | 287 | ### VisualStudioCode ### 288 | .vscode/* 289 | !.vscode/settings.json 290 | !.vscode/tasks.json 291 | !.vscode/launch.json 292 | !.vscode/extensions.json 293 | !.vscode/*.code-snippets 294 | 295 | # Local History for Visual Studio Code 296 | .history/ 297 | 298 | # Built Visual Studio Code Extensions 299 | *.vsix 300 | 301 | ### VisualStudioCode Patch ### 302 | # Ignore all local history of files 303 | .history 304 | .ionide 305 | 306 | ### Windows ### 307 | # Windows thumbnail cache files 308 | Thumbs.db 309 | Thumbs.db:encryptable 310 | ehthumbs.db 311 | ehthumbs_vista.db 312 | 313 | # Dump file 314 | *.stackdump 315 | 316 | # Folder config file 317 | [Dd]esktop.ini 318 | 319 | # Recycle Bin used on file shares 320 | $RECYCLE.BIN/ 321 | 322 | # Windows Installer files 323 | *.cab 324 | *.msi 325 | *.msix 326 | *.msm 327 | *.msp 328 | 329 | # Windows shortcuts 330 | *.lnk 331 | 332 | # End of https://www.toptal.com/developers/gitignore/api/python,pycharm+all,visualstudiocode,macos,windows 333 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Domain-Driven-Design(DDD) Example 2 | 3 | ## Intro 4 | I've adopted the DDD pattern for my recent FastAPI project. 5 | DDD makes it easier to implement complex domain problems. 6 | Improved readability and easy code fix have significantly improved productivity. 7 | As a result, stable but flexible project management has become possible. 8 | I'm very satisfied with it, so I'd like to share this experience and knowledge. 9 | 10 | ### Why DDD? 11 | Using DDD makes it easy to maintain collaboration with domain experts, not only engineers. 12 | - It is possible to prevent the mental model and the actual software from being dualized. 13 | - Business logic is easy to manage. 14 | - Infrastructure change is flexible. 15 | 16 | 17 | ### Objective 18 | - Let's create a simple hotel reservation system and see how each component of DDD is implemented. 19 | - Don't go too deep into topics like event sourcing. 20 | - Considering the running curve, this project consists only of essential DDD components. 21 | 22 | 23 | ## Implementation 24 | ### ERD 25 | > NOTES: The diagram below represents only the database tables. 26 | 27 | ![erd](./docs/image/erd.png) 28 | 29 | ### Bounded Context 30 | 31 | ![bounded-context](./docs/image/bounded-context.png) 32 | 33 | - Display(Handling tasks related to the hotel room display) 34 | - List Rooms 35 | - Reception(Handling tasks related to the hotel room reservation) 36 | - Make a reservation 37 | - Change the reservation details 38 | - Cancel a reservation 39 | - Check-in & Check-out 40 | 41 | Reservation and reception can also be isolated, but let's say that reception handles it altogether for now. 42 | 43 | ### Project Structure 44 | ```tree 45 | src 46 | ├── reception 47 | │ ├── application 48 | │ │ └── use_case 49 | │ │ ├── query 50 | │ │ └── command 51 | │ ├── domain 52 | │ │ ├── entity 53 | │ │ ├── exception 54 | │ │ ├── service 55 | │ │ └── value_object 56 | │ ├── infra 57 | │ │ ├── repository 58 | │ │ └── external_apis 59 | │ └── presentation 60 | │ ├── grpc 61 | │ └── rest 62 | │ ├── request 63 | │ └── response 64 | ├── display 65 | │ ├── application 66 | │ ├── domain 67 | │ ├── infra 68 | │ └── presentation 69 | └── shared_kernel 70 | ├── domain 71 | └── infra 72 | ├── database 73 | ├── fastapi 74 | └── log 75 | ``` 76 | 77 | ### DDD Components 78 | #### 1. Entity: Definition 79 | ```python 80 | from dataclasses import dataclass, field 81 | 82 | 83 | class Entity: 84 | id: int = field(init=False) 85 | 86 | def __eq__(self, other: Any) -> bool: 87 | if isinstance(other, type(self)): 88 | return self.id == other.id 89 | return False 90 | 91 | def __hash__(self): 92 | return hash(self.id) 93 | 94 | 95 | class AggregateRoot(Entity): 96 | pass 97 | 98 | 99 | @dataclass(eq=False, slots=True) 100 | class Reservation(AggregateRoot): 101 | room: Room 102 | reservation_number: ReservationNumber 103 | reservation_status: ReservationStatus 104 | date_in: datetime 105 | date_out: datetime 106 | guest: Guest 107 | ``` 108 | 109 | - Entity mix-in
110 | The entity is an object that have a distinct identity. 111 | I will implement `__eq__()` and `__hash__()`, to use it as a mix-in for dataclass. 112 | 113 | - AggregateRoot mix-in
114 | A DDD aggregate is a cluster of domain objects that can be treated as a single unit. 115 | An aggregate root is an entry point of an aggregate. 116 | Any references from outside the aggregate should only go to the aggregate root. 117 | The root can thus ensure the integrity of the aggregate as a whole. 118 | I will define an empty class called `AggregateRoot` and explicitly mark it. 119 | 120 | - Entity Implementation
121 | To use `__eq__()` from `Entity` mix-in, add `eq=False`. 122 | From Python 3.10, `slots=True` makes dataclass more memory-efficient. 123 | 124 | - Value Object
125 | With sqlalchemy, you can use value objects within entity when reading & saving data from a repository. 126 | I will introduce the details later. 127 | 128 | #### 2. Entity: Life Cycle 129 | 130 | ```python 131 | @dataclass(eq=False, slots=True) 132 | class Reservation(AggregateRoot): 133 | # ... 134 | 135 | @classmethod 136 | def make( 137 | cls, room: Room, date_in: datetime, date_out: datetime, guest: Guest 138 | ) -> Reservation: 139 | room.reserve() 140 | return cls( 141 | room=room, 142 | date_in=date_in, 143 | date_out=date_out, 144 | guest=guest, 145 | reservation_number=ReservationNumber.generate(), 146 | reservation_status=ReservationStatus.IN_PROGRESS, 147 | ) 148 | 149 | def cancel(self): 150 | if not self.reservation_status.in_progress: 151 | raise ReservationStatusException 152 | 153 | self.reservation_status = ReservationStatus.CANCELLED 154 | 155 | def check_in(self): 156 | # ... 157 | 158 | def check_out(self): 159 | # ... 160 | 161 | def change_guest(self, guest: Guest): 162 | # ... 163 | ``` 164 | 165 | By implementing the method according to the entity's life cycle, you can expect how it evolves when reading it. 166 | 167 | - Creation
168 | Declare a `class method` and use it when creating an entity. 169 | 170 | - Changes
171 | Declare an `instance method` and use it when changing an entity. 172 | 173 | #### 3. Entity: Table Mapping 174 | > NOTE: This is the most beautiful part of implementing DDD with sqlalchemy. 175 | - [ddd_hotel/database/orm.py](src/shared_kernel/infra/database/orm.py) 176 | 177 | ```python 178 | from sqlalchemy import MetaData, Table, Column, Integer, String, Text, ForeignKey, DateTime 179 | from sqlalchemy.orm import registry 180 | 181 | metadata = MetaData() 182 | mapper_registry = registry() 183 | 184 | room_table = Table( 185 | "hotel_room", 186 | metadata, 187 | Column("id", Integer, primary_key=True, autoincrement=True), 188 | Column("number", String(20), nullable=False), 189 | Column("status", String(20), nullable=False), 190 | Column("image_url", String(200), nullable=False), 191 | Column("description", Text, nullable=True), 192 | UniqueConstraint("number", name="uix_hotel_room_number"), 193 | ) 194 | 195 | reservation_table = Table( 196 | "room_reservation", 197 | metadata, 198 | Column("id", Integer, primary_key=True, autoincrement=True), 199 | Column("room_id", Integer, ForeignKey("hotel_room.id"), nullable=False), 200 | Column("number", String(20), nullable=False), 201 | Column("status", String(20), nullable=False), 202 | Column("date_in", DateTime(timezone=True)), 203 | Column("date_out", DateTime(timezone=True)), 204 | Column("guest_mobile", String(20), nullable=False), 205 | Column("guest_name", String(50), nullable=True), 206 | ) 207 | 208 | 209 | def init_orm_mappers(): 210 | from reception.domain.entity.room import Room as ReceptionRoomEntity 211 | from reception.domain.entity.reservation import Reservation as ReceptionReservationEntity 212 | 213 | mapper_registry.map_imperatively( 214 | ReceptionRoomEntity, 215 | room_table, 216 | properties={ 217 | "room_status": composite(RoomStatus.from_value, room_table.c.status), 218 | } 219 | ) 220 | mapper_registry.map_imperatively( 221 | ReceptionReservationEntity, 222 | reservation_table, 223 | properties={ 224 | "room": relationship( 225 | Room, backref="reservations", order_by=reservation_table.c.id.desc, lazy="joined" 226 | ), 227 | "reservation_number": composite(ReservationNumber.from_value, reservation_table.c.number), 228 | "reservation_status": composite(ReservationStatus.from_value, reservation_table.c.status), 229 | "guest": composite(Guest, reservation_table.c.guest_mobile, reservation_table.c.guest_name), 230 | } 231 | ) 232 | 233 | from display.domain.entity.room import Room as DisplayRoomEntity 234 | 235 | mapper_registry.map_imperatively( 236 | DisplayRoomEntity, 237 | room_table, 238 | properties={ 239 | "room_status": composite(RoomStatus.from_value, room_table.c.status), 240 | } 241 | ) 242 | ``` 243 | 244 | ```python 245 | # call this after app running 246 | init_orm_mappers() 247 | ``` 248 | 249 | Because entities do not need to know the implementation of the database table, let's use sqlalchemy's [imperative mapping](https://docs.sqlalchemy.org/en/14/orm/mapping_styles.html#imperative-mapping) to separate entity definitions and table definitions. 250 | 251 | Because name conflicts can occur when mapping tables and entities, the `number` is replaced like `reservation_number`. 252 | 253 | If you want to keep using the `number` as it is, you can change the original `number` to `_number` first. 254 | 255 | 256 | ```python 257 | @dataclass(eq=False, slots=True) 258 | class Room(Entity): 259 | number: str 260 | room_status: RoomStatus 261 | ``` 262 | 263 | Entities only need to use logically required data among the columns defined in the table. 264 | For example, in the `reservation` domain, you don't need to know the `image` of the `room`, so only `name`, `status` is defined in the `room`. 265 | 266 | #### 4. Value Object 267 | ```python 268 | from pydantic import constr 269 | 270 | 271 | mobile_type = constr(regex=r"\+[0-9]{2,3}-[0-9]{2}-[0-9]{4}-[0-9]{4}") 272 | 273 | @dataclass(slots=True) 274 | class Guest(ValueObject): 275 | mobile: mobile_type 276 | name: str | None = None 277 | ``` 278 | 279 | A value object is an object that matter only as the combination of its attributes. 280 | Guest A's name and mobile should be treated as a single unit, so make it a value object. 281 | 282 | Using sqlalchemy's [composite column type](https://docs.sqlalchemy.org/en/14/orm/composites.html#composite-column-types), it allows you to implement value objects by changing columns to an object that fits your needs when you load data. 283 | 284 | Let's define the mix-in as follows and inherit it when implementing a value object. 285 | 286 | ```python 287 | class ValueObject: 288 | def __composite_values__(self): 289 | return self.value, 290 | 291 | @classmethod 292 | def from_value(cls, value: Any) -> ValueObjectType | None: 293 | if isinstance(cls, EnumMeta): 294 | for item in cls: 295 | if item.value == value: 296 | return item 297 | raise ValueObjectEnumError 298 | 299 | instance = cls(value=value) 300 | return instance 301 | 302 | ``` 303 | 304 | If you define the `__composite_values_()` method, sqlalchemy separates the object and puts them in the columns when you save the data. 305 | 306 | > NOTE: The , in the return of `__composite_value__()` is not a typo. 307 | 308 | ```python 309 | class RoomStatus(ValueObject, str, Enum): 310 | AVAILABLE = "AVAILABLE" 311 | RESERVED = "RESERVED" 312 | OCCUPIED = "OCCUPIED" 313 | 314 | 315 | @dataclass(slots=True) 316 | class ReservationNumber(ValueObject): 317 | DATETIME_FORMAT: ClassVar[str] = "%y%m%d%H%M%S" 318 | RANDOM_STR_LENGTH: ClassVar[int] = 7 319 | 320 | value: str 321 | 322 | @classmethod 323 | def generate(cls) -> ReservationNumber: 324 | time_part: str = datetime.utcnow().strftime(cls.DATETIME_FORMAT) 325 | random_strings: str = ''.join( 326 | random.choice(string.ascii_uppercase + string.digits) 327 | for _ in range(cls.RANDOM_STR_LENGTH) 328 | ) 329 | return cls(value=time_part + ":" + random_strings) 330 | ``` 331 | 332 | `ReservationNumber` intentionally used the name `value` for a single attribute to leverage `__composite_values__()` in `ValueObject` class. 333 | 334 | 335 | ```python 336 | @dataclass(slots=True) 337 | class Guest(ValueObject): 338 | mobile: mobile_type 339 | name: str | None = None 340 | 341 | def __composite_values__(self): 342 | return self.mobile, self.name 343 | ``` 344 | 345 | If a value object consists of more than one column, you must override the `__composite_values__()` as shown above. 346 | 347 | #### 5. Exception 348 | ```python 349 | class ReservationStatusException(BaseMsgException): 350 | message = "Invalid request for current reservation status." 351 | 352 | 353 | @dataclass(eq=False, slots=True) 354 | class Reservation(AggregateRoot): 355 | # ... 356 | 357 | def cancel(self): 358 | if not self.reservation_status.in_progress: 359 | raise ReservationStatusException 360 | 361 | self.reservation_status = ReservationStatus.CANCELLED 362 | ``` 363 | 364 | By defining and using domain exceptions, the cohesion can be increased. 365 | 366 | #### Dependency Injection 367 | FastAPI's `Depends` makes it easy to implement **Dependency Injection** between layers. 368 | And you can achieve [Inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control) with [Dependency Injector](https://python-dependency-injector.ets-labs.org/index.html). 369 | 370 | - [presentation/rest/reception.py](src/reception/presentation/rest/api.py) 371 | 372 | ```python 373 | @router.get("/reservations/{reservation_number}") 374 | @inject 375 | def get_reservation( 376 | reservation_number: str, 377 | reservation_query: ReservationQueryUseCase = Depends( 378 | Provide[AppContainer.reception.reservation_query] 379 | ), 380 | ): 381 | try: 382 | reservation: Reservation = reservation_query.get_reservation( 383 | reservation_number=reservation_number 384 | ) 385 | except ReservationNotFoundException as e: 386 | raise HTTPException( 387 | status_code=status.HTTP_404_NOT_FOUND, 388 | detail=e.message, 389 | ) 390 | return ReservationResponse( 391 | detail="ok", 392 | result=ReservationSchema.build(reservation=reservation), 393 | ) 394 | ``` 395 | 396 | - [application/use_case/query.py](src/reception/application/use_case/query.py) 397 | ```python 398 | class ReservationQueryUseCase: 399 | def __init__( 400 | self, 401 | reservation_repo: ReservationRDBRepository, 402 | db_session: Callable[[], ContextManager[Session]], 403 | ): 404 | self.reservation_repo = reservation_repo 405 | self.db_session = db_session 406 | 407 | def get_reservation(self, reservation_number: str) -> Reservation: 408 | reservation_number = ReservationNumber.from_value(value=reservation_number) 409 | 410 | with self.db_session() as session: 411 | reservation: Reservation | None = ( 412 | self.reservation_repo.get_reservation_by_reservation_number( 413 | session=session, reservation_number=reservation_number 414 | ) 415 | ) 416 | 417 | if not reservation: 418 | raise ReservationNotFoundException 419 | 420 | return reservation 421 | ``` 422 | 423 | - [infra/repository/repository.py](src/reception/infra/repository.py) 424 | ```python 425 | class ReservationRDBRepository(RDBRepository): 426 | @staticmethod 427 | def get_reservation_by_reservation_number( 428 | session: Session, reservation_number: ReservationNumber 429 | ) -> Reservation | None: 430 | return session.query(Reservation).filter_by(reservation_number=reservation_number).first() 431 | ``` 432 | 433 | #### Schema 434 | Pydantic makes it easy to implement the request and response schema. 435 | - [presentation/rest/schema/request.py](src/reception/presentation/rest/request.py) 436 | ```python 437 | class CreateReservationRequest(BaseModel): 438 | room_number: str 439 | date_in: datetime 440 | date_out: datetime 441 | guest_mobile: mobile_type 442 | guest_name: str | None = None 443 | ``` 444 | 445 | - [application/Schema/response.py](src/reception/presentation/rest/response.py) 446 | ```python 447 | class ReservationSchema(BaseModel): 448 | room: RoomSchema 449 | reservation_number: str 450 | status: ReservationStatus 451 | date_in: datetime 452 | date_out: datetime 453 | guest: GuestSchema 454 | 455 | @classmethod 456 | def build(cls, reservation: Reservation) -> ReservationSchema: 457 | return cls( 458 | room=RoomSchema.from_entity(reservation.room), 459 | reservation_number=reservation.reservation_number.value, 460 | status=reservation.reservation_status, 461 | date_in=reservation.date_in, 462 | date_out=reservation.date_out, 463 | guest=GuestSchema.from_entity(reservation.guest), 464 | ) 465 | 466 | class ReservationResponse(BaseResponse): 467 | result: ReservationSchema 468 | ``` 469 | 470 | #### Run server 471 | ```shell 472 | $ uvicorn shared_kernel.infra.fastapi.main:app --reload 473 | ``` 474 | 475 | #### Requirements 476 | - Python 3.10+ 477 | - 3.10 and lower versions can also take the key concepts -------------------------------------------------------------------------------- /docs/image/bounded-context.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/docs/image/bounded-context.png -------------------------------------------------------------------------------- /docs/image/erd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/docs/image/erd.png -------------------------------------------------------------------------------- /src/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = shared_kernel/infra/database/migrations 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python-dateutil library that can be 20 | # installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to dateutil.tz.gettz() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to alembic/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # the output encoding used when revision files 55 | # are written from script.py.mako 56 | # output_encoding = utf-8 57 | 58 | # sqlalchemy.url = driver://user:pass@localhost/dbname 59 | 60 | 61 | [post_write_hooks] 62 | # post_write_hooks defines scripts or Python functions that are run 63 | # on newly generated revision scripts. See the documentation for further 64 | # detail and examples 65 | 66 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 67 | # hooks = black 68 | # black.type = console_scripts 69 | # black.entrypoint = black 70 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 71 | 72 | # Logging configuration 73 | [loggers] 74 | keys = root,sqlalchemy,alembic 75 | 76 | [handlers] 77 | keys = console 78 | 79 | [formatters] 80 | keys = generic 81 | 82 | [logger_root] 83 | level = WARN 84 | handlers = console 85 | qualname = 86 | 87 | [logger_sqlalchemy] 88 | level = WARN 89 | handlers = 90 | qualname = sqlalchemy.engine 91 | 92 | [logger_alembic] 93 | level = INFO 94 | handlers = 95 | qualname = alembic 96 | 97 | [handler_console] 98 | class = StreamHandler 99 | args = (sys.stderr,) 100 | level = NOTSET 101 | formatter = generic 102 | 103 | [formatter_generic] 104 | format = %(levelname)-5.5s [%(name)s] %(message)s 105 | datefmt = %H:%M:%S 106 | -------------------------------------------------------------------------------- /src/display/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/__init__.py -------------------------------------------------------------------------------- /src/display/application/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/application/__init__.py -------------------------------------------------------------------------------- /src/display/application/use_case/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/application/use_case/__init__.py -------------------------------------------------------------------------------- /src/display/application/use_case/query.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, ContextManager, List 2 | 3 | from sqlalchemy.orm import Query, Session 4 | 5 | from display.domain.entity.room import Room 6 | from display.infra.repository import RoomRDBRepository 7 | from shared_kernel.domain.value_object import RoomStatus 8 | 9 | 10 | class DisplayQueryUseCase: 11 | def __init__(self, room_repo: RoomRDBRepository, db_session: Callable[[], ContextManager[Session]]): 12 | self.room_repo = room_repo 13 | self.db_session = db_session 14 | 15 | def get_rooms(self, room_status: RoomStatus) -> List[Room]: 16 | with self.db_session() as session: 17 | rooms: List[Room] = list( 18 | self.room_repo.get_rooms_by_status(session=session, room_status=room_status) 19 | ) 20 | return rooms 21 | -------------------------------------------------------------------------------- /src/display/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/domain/__init__.py -------------------------------------------------------------------------------- /src/display/domain/entity/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/domain/entity/__init__.py -------------------------------------------------------------------------------- /src/display/domain/entity/room.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from shared_kernel.domain.entity import AggregateRoot 4 | from shared_kernel.domain.value_object import RoomStatus 5 | 6 | 7 | @dataclass(eq=False, slots=True) 8 | class Room(AggregateRoot): 9 | number: str 10 | room_status: RoomStatus 11 | image_url: str 12 | description: str | None = None 13 | -------------------------------------------------------------------------------- /src/display/infra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/infra/__init__.py -------------------------------------------------------------------------------- /src/display/infra/container.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | 3 | from display.application.use_case.query import DisplayQueryUseCase 4 | from display.infra.repository import RoomRDBRepository 5 | from shared_kernel.infra.database.connection import get_db_session 6 | 7 | 8 | class DisplayContainer(containers.DeclarativeContainer): 9 | room_repo = providers.Factory(RoomRDBRepository) 10 | 11 | query = providers.Factory( 12 | DisplayQueryUseCase, 13 | room_repo=room_repo, 14 | db_session=get_db_session, 15 | ) 16 | -------------------------------------------------------------------------------- /src/display/infra/external_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/infra/external_api/__init__.py -------------------------------------------------------------------------------- /src/display/infra/repository.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Query, Session 2 | 3 | from display.domain.entity.room import Room 4 | from shared_kernel.domain.value_object import RoomStatus 5 | from shared_kernel.infra.database.repository import RDBReadRepository 6 | 7 | 8 | class RoomRDBRepository(RDBReadRepository): 9 | @staticmethod 10 | def get_rooms_by_status(session: Session, room_status: RoomStatus) -> Query: 11 | return session.query(Room).filter_by(status=room_status) 12 | -------------------------------------------------------------------------------- /src/display/presentation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/presentation/__init__.py -------------------------------------------------------------------------------- /src/display/presentation/grpc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/presentation/grpc/__init__.py -------------------------------------------------------------------------------- /src/display/presentation/rest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/presentation/rest/__init__.py -------------------------------------------------------------------------------- /src/display/presentation/rest/api.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from dependency_injector.wiring import Provide, inject 4 | from fastapi import APIRouter, Depends 5 | 6 | from display.presentation.rest.request import GetRoomRequest 7 | from display.presentation.rest.response import RoomSchema, RoomResponse 8 | from display.application.use_case.query import DisplayQueryUseCase 9 | from display.domain.entity.room import Room 10 | from shared_kernel.infra.container import AppContainer 11 | 12 | router = APIRouter(prefix="/display") 13 | 14 | 15 | @router.get("/rooms") 16 | @inject 17 | def get_rooms( 18 | request: GetRoomRequest = Depends(), 19 | display_query: DisplayQueryUseCase = Depends(Provide[AppContainer.display.query]), 20 | ) -> RoomResponse: 21 | rooms: List[Room] = display_query.get_rooms(room_status=request.status) 22 | return RoomResponse( 23 | detail="ok", 24 | result=[RoomSchema.from_orm(room) for room in rooms] 25 | ) 26 | -------------------------------------------------------------------------------- /src/display/presentation/rest/request.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from shared_kernel.domain.value_object import RoomStatus 4 | 5 | 6 | class GetRoomRequest(BaseModel): 7 | status: RoomStatus 8 | -------------------------------------------------------------------------------- /src/display/presentation/rest/response.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | from shared_kernel.presentation.response import BaseResponse 6 | from shared_kernel.domain.value_object import RoomStatus 7 | 8 | 9 | class RoomSchema(BaseModel): 10 | id: int 11 | number: str 12 | status: RoomStatus 13 | image_url: str 14 | description: str | None 15 | 16 | class Config: 17 | orm_mode = True 18 | 19 | 20 | class RoomResponse(BaseResponse): 21 | result: List[RoomSchema] 22 | -------------------------------------------------------------------------------- /src/pytest.ini: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/pytest.ini -------------------------------------------------------------------------------- /src/reception/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/__init__.py -------------------------------------------------------------------------------- /src/reception/application/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/application/__init__.py -------------------------------------------------------------------------------- /src/reception/application/use_case/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/application/use_case/__init__.py -------------------------------------------------------------------------------- /src/reception/application/use_case/command.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, ContextManager 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from reception.presentation.rest.request import CreateReservationRequest, UpdateGuestRequest 6 | from reception.application.use_case.query import ReservationQueryUseCase 7 | from reception.domain.entity.reservation import Reservation 8 | from reception.domain.entity.room import Room 9 | from reception.domain.service.check_in import CheckInService 10 | from reception.domain.value_object.guest import Guest, mobile_type 11 | from reception.infra.repository import ReservationRDBRepository 12 | 13 | 14 | class ReservationCommandUseCase: 15 | def __init__( 16 | self, 17 | reservation_repo: ReservationRDBRepository, 18 | reservation_query: ReservationQueryUseCase, 19 | check_in_service: CheckInService, 20 | db_session: Callable[[], ContextManager[Session]], 21 | ): 22 | self.reservation_repo = reservation_repo 23 | self.reservation_query = reservation_query 24 | self.check_in_service = check_in_service 25 | self.db_session = db_session 26 | 27 | def make_reservation(self, request: CreateReservationRequest) -> Reservation: 28 | room: Room = self.reservation_query.get_room(room_number=request.room_number) 29 | reservation = Reservation.make( 30 | room=room, 31 | date_in=request.date_in, 32 | date_out=request.date_out, 33 | guest=Guest(mobile=request.guest_mobile, name=request.guest_name) 34 | ) 35 | with self.db_session() as session: 36 | self.reservation_repo.add(session=session, instance=reservation) 37 | self.reservation_repo.commit(session=session) 38 | return reservation 39 | 40 | def update_guest_info(self, reservation_number: str, request: UpdateGuestRequest) -> Reservation: 41 | reservation: Reservation = self.reservation_query.get_reservation(reservation_number=reservation_number) 42 | 43 | guest: Guest = Guest(mobile=request.guest_mobile, name=request.guest_name) 44 | reservation.change_guest(guest=guest) 45 | 46 | with self.db_session() as session: 47 | self.reservation_repo.add(session=session, instance=reservation) 48 | self.reservation_repo.commit(session=session) 49 | return reservation 50 | 51 | def check_in(self, reservation_number: str, mobile: mobile_type) -> Reservation: 52 | reservation: Reservation = self.reservation_query.get_reservation(reservation_number=reservation_number) 53 | self.check_in_service.check_in(reservation=reservation, mobile=mobile) 54 | 55 | with self.db_session() as session: 56 | self.reservation_repo.add(session=session, instance=reservation) 57 | self.reservation_repo.commit(session=session) 58 | return reservation 59 | 60 | def check_out(self, reservation_number: str) -> Reservation: 61 | reservation: Reservation = self.reservation_query.get_reservation(reservation_number=reservation_number) 62 | reservation.check_out() 63 | 64 | with self.db_session() as session: 65 | self.reservation_repo.add(session=session, instance=reservation) 66 | self.reservation_repo.commit(session=session) 67 | return reservation 68 | 69 | def cancel(self, reservation_number: str) -> Reservation: 70 | reservation: Reservation = self.reservation_query.get_reservation(reservation_number=reservation_number) 71 | reservation.cancel() 72 | 73 | with self.db_session() as session: 74 | self.reservation_repo.add(session=session, instance=reservation) 75 | self.reservation_repo.commit(session=session) 76 | return reservation 77 | -------------------------------------------------------------------------------- /src/reception/application/use_case/query.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, ContextManager 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from reception.domain.entity.room import Room 6 | from reception.domain.exception.reservation import ReservationNotFoundException 7 | from reception.domain.entity.reservation import Reservation 8 | from reception.domain.exception.room import RoomNotFoundException 9 | from reception.domain.value_object.reservation import ReservationNumber 10 | from reception.infra.repository import ReservationRDBRepository 11 | 12 | 13 | class ReservationQueryUseCase: 14 | def __init__( 15 | self, 16 | reservation_repo: ReservationRDBRepository, 17 | db_session: Callable[[], ContextManager[Session]], 18 | ): 19 | self.reservation_repo = reservation_repo 20 | self.db_session = db_session 21 | 22 | def get_room(self, room_number: str) -> Room: 23 | with self.db_session() as session: 24 | room: Room | None = ( 25 | self.reservation_repo.get_room_by_room_number(session=session, room_number=room_number) 26 | ) 27 | if not room: 28 | raise RoomNotFoundException 29 | return room 30 | 31 | def get_reservation(self, reservation_number: str) -> Reservation: 32 | reservation_number = ReservationNumber.from_value(value=reservation_number) 33 | 34 | with self.db_session() as session: 35 | reservation: Reservation | None = ( 36 | self.reservation_repo.get_reservation_by_reservation_number( 37 | session=session, reservation_number=reservation_number 38 | ) 39 | ) 40 | 41 | if not reservation: 42 | raise ReservationNotFoundException 43 | return reservation 44 | -------------------------------------------------------------------------------- /src/reception/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/domain/__init__.py -------------------------------------------------------------------------------- /src/reception/domain/entity/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/domain/entity/__init__.py -------------------------------------------------------------------------------- /src/reception/domain/entity/reservation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | 6 | from reception.domain.exception.reservation import ReservationStatusException 7 | from reception.domain.exception.room import RoomStatusException 8 | from reception.domain.entity.room import Room 9 | from reception.domain.value_object.guest import Guest 10 | from reception.domain.value_object.reservation import ReservationNumber 11 | from shared_kernel.domain.entity import AggregateRoot 12 | from shared_kernel.domain.value_object import ReservationStatus, RoomStatus 13 | 14 | 15 | @dataclass(eq=False, slots=True) 16 | class Reservation(AggregateRoot): 17 | room: Room 18 | reservation_number: ReservationNumber 19 | reservation_status: ReservationStatus 20 | date_in: datetime 21 | date_out: datetime 22 | guest: Guest 23 | 24 | @classmethod 25 | def make( 26 | cls, room: Room, date_in: datetime, date_out: datetime, guest: Guest 27 | ) -> Reservation: 28 | room.reserve() 29 | return cls( 30 | room=room, 31 | date_in=date_in, 32 | date_out=date_out, 33 | guest=guest, 34 | reservation_number=ReservationNumber.generate(), 35 | reservation_status=ReservationStatus.IN_PROGRESS, 36 | ) 37 | 38 | def cancel(self): 39 | if not self.reservation_status.in_progress: 40 | raise ReservationStatusException 41 | 42 | self.reservation_status = ReservationStatus.CANCELLED 43 | self.room.room_status = RoomStatus.AVAILABLE 44 | 45 | def check_in(self): 46 | if not self.room.room_status.is_reserved: 47 | raise RoomStatusException 48 | 49 | if not self.reservation_status.in_progress: 50 | raise ReservationStatusException 51 | 52 | self.room.room_status = RoomStatus.OCCUPIED 53 | 54 | def check_out(self): 55 | if not self.room.room_status.is_occupied: 56 | raise RoomStatusException 57 | 58 | if not self.reservation_status.in_progress: 59 | raise ReservationStatusException 60 | 61 | self.reservation_status = ReservationStatus.COMPLETE 62 | self.room.room_status = RoomStatus.AVAILABLE 63 | 64 | def change_guest(self, guest: Guest): 65 | self.guest = guest 66 | -------------------------------------------------------------------------------- /src/reception/domain/entity/room.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from reception.domain.exception.room import RoomStatusException 4 | from shared_kernel.domain.entity import Entity 5 | from shared_kernel.domain.value_object import RoomStatus 6 | 7 | 8 | @dataclass(eq=False, slots=True) 9 | class Room(Entity): 10 | number: str 11 | room_status: RoomStatus 12 | 13 | def reserve(self): 14 | if not self.room_status.is_available: 15 | raise RoomStatusException 16 | 17 | self.room_status = RoomStatus.RESERVED 18 | -------------------------------------------------------------------------------- /src/reception/domain/exception/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/domain/exception/__init__.py -------------------------------------------------------------------------------- /src/reception/domain/exception/check_in.py: -------------------------------------------------------------------------------- 1 | from shared_kernel.domain.exception import BaseMsgException 2 | 3 | 4 | class CheckInDateException(BaseMsgException): 5 | message = "Invalid date for check-in." 6 | 7 | 8 | class CheckInAuthenticationException(BaseMsgException): 9 | message = "Invalid guest authentication for check-in." 10 | -------------------------------------------------------------------------------- /src/reception/domain/exception/reservation.py: -------------------------------------------------------------------------------- 1 | from shared_kernel.domain.exception import BaseMsgException 2 | 3 | 4 | class ReservationNotFoundException(BaseMsgException): 5 | message = "Reservation is not found." 6 | 7 | 8 | class ReservationStatusException(BaseMsgException): 9 | message = "Invalid request for current reservation status." 10 | -------------------------------------------------------------------------------- /src/reception/domain/exception/room.py: -------------------------------------------------------------------------------- 1 | from shared_kernel.domain.exception import BaseMsgException 2 | 3 | 4 | class RoomNotFoundException(BaseMsgException): 5 | message = "Room is not found." 6 | 7 | 8 | class RoomStatusException(BaseMsgException): 9 | message = "Invalid request for current room status." 10 | -------------------------------------------------------------------------------- /src/reception/domain/service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/domain/service/__init__.py -------------------------------------------------------------------------------- /src/reception/domain/service/check_in.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from reception.domain.exception.check_in import CheckInAuthenticationException, CheckInDateException 4 | from reception.domain.entity.reservation import Reservation 5 | from reception.domain.value_object.guest import mobile_type 6 | 7 | 8 | class CheckInService: 9 | _EARLY_CHECK_IN_OFFSET: int = 3 10 | _LATE_CHECK_IN_OFFSET: int = 6 11 | 12 | @staticmethod 13 | def _is_valid_date(reservation: Reservation) -> bool: 14 | return ( 15 | reservation.date_in - timedelta(hours=CheckInService._EARLY_CHECK_IN_OFFSET) 16 | <= datetime.utcnow() 17 | <= reservation.date_out - timedelta(hours=CheckInService._LATE_CHECK_IN_OFFSET) 18 | ) 19 | 20 | @staticmethod 21 | def _is_valid_guest(reservation: Reservation, mobile: mobile_type) -> bool: 22 | # mobile authentication 23 | return reservation.guest.mobile == mobile 24 | 25 | def check_in(self, reservation: Reservation, mobile: str) -> None: 26 | if not self._is_valid_date(reservation=reservation): 27 | raise CheckInDateException 28 | 29 | if not self._is_valid_guest(reservation=reservation, mobile=mobile): 30 | raise CheckInAuthenticationException 31 | 32 | reservation.check_in() 33 | -------------------------------------------------------------------------------- /src/reception/domain/value_object/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/domain/value_object/__init__.py -------------------------------------------------------------------------------- /src/reception/domain/value_object/guest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from pydantic import constr 6 | 7 | from shared_kernel.domain.value_object import ValueObject 8 | 9 | mobile_type = constr(regex=r"\+[0-9]{2,3}-[0-9]{2}-[0-9]{4}-[0-9]{4}") 10 | 11 | 12 | @dataclass(slots=True) 13 | class Guest(ValueObject): 14 | mobile: mobile_type 15 | name: str | None = None 16 | 17 | def __composite_values__(self): 18 | return self.mobile, self.name 19 | -------------------------------------------------------------------------------- /src/reception/domain/value_object/reservation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import random 4 | import string 5 | from dataclasses import dataclass 6 | from datetime import datetime 7 | from typing import ClassVar 8 | 9 | from shared_kernel.domain.value_object import ValueObject 10 | 11 | 12 | @dataclass(slots=True) 13 | class ReservationNumber(ValueObject): 14 | _DATETIME_FORMAT: ClassVar[str] = "%y%m%d%H%M%S" 15 | _RANDOM_STR_LENGTH: ClassVar[int] = 7 16 | 17 | value: str 18 | 19 | @classmethod 20 | def generate(cls) -> ReservationNumber: 21 | time_part: str = datetime.utcnow().strftime(cls._DATETIME_FORMAT) 22 | random_strings: str = ''.join( 23 | random.choice(string.ascii_uppercase + string.digits) for _ in range(cls._RANDOM_STR_LENGTH) 24 | ) 25 | return cls(value=time_part + ":" + random_strings) 26 | -------------------------------------------------------------------------------- /src/reception/infra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/infra/__init__.py -------------------------------------------------------------------------------- /src/reception/infra/container.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | 3 | from reception.application.use_case.command import ReservationCommandUseCase 4 | from reception.application.use_case.query import ReservationQueryUseCase 5 | from reception.domain.service.check_in import CheckInService 6 | from reception.infra.repository import ReservationRDBRepository 7 | from shared_kernel.infra.database.connection import get_db_session 8 | 9 | 10 | class ReceptionContainer(containers.DeclarativeContainer): 11 | reservation_repo = providers.Factory(ReservationRDBRepository) 12 | 13 | check_in_service = providers.Factory(CheckInService) 14 | 15 | reservation_query = providers.Factory( 16 | ReservationQueryUseCase, 17 | reservation_repo=reservation_repo, 18 | db_session=get_db_session, 19 | ) 20 | reservation_command = providers.Factory( 21 | ReservationCommandUseCase, 22 | reservation_repo=reservation_repo, 23 | reservation_query=reservation_query, 24 | check_in_service=check_in_service, 25 | db_session=get_db_session, 26 | ) 27 | -------------------------------------------------------------------------------- /src/reception/infra/external_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/infra/external_api/__init__.py -------------------------------------------------------------------------------- /src/reception/infra/repository.py: -------------------------------------------------------------------------------- 1 | from reception.domain.entity.reservation import Reservation 2 | from reception.domain.entity.room import Room 3 | from reception.domain.value_object.reservation import ReservationNumber 4 | from shared_kernel.infra.database.repository import RDBRepository 5 | 6 | 7 | class ReservationRDBRepository(RDBRepository): 8 | @staticmethod 9 | def get_reservation_by_reservation_number(session, reservation_number: ReservationNumber) -> Reservation | None: 10 | return session.query(Reservation).filter_by(reservation_number=reservation_number).first() 11 | 12 | @staticmethod 13 | def get_room_by_room_number(session, room_number: str) -> Room | None: 14 | return session.query(Room).filter_by(number=room_number).first() 15 | -------------------------------------------------------------------------------- /src/reception/presentation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/presentation/__init__.py -------------------------------------------------------------------------------- /src/reception/presentation/grpc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/presentation/grpc/__init__.py -------------------------------------------------------------------------------- /src/reception/presentation/rest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/presentation/rest/__init__.py -------------------------------------------------------------------------------- /src/reception/presentation/rest/api.py: -------------------------------------------------------------------------------- 1 | from dependency_injector.wiring import Provide, inject 2 | from fastapi import APIRouter, Body, Depends, HTTPException 3 | from starlette import status 4 | 5 | from reception.application.use_case.command import ReservationCommandUseCase 6 | from reception.application.use_case.query import ReservationQueryUseCase 7 | from reception.domain.exception.check_in import CheckInAuthenticationException, CheckInDateException 8 | from reception.domain.exception.reservation import ReservationNotFoundException, ReservationStatusException 9 | from reception.domain.exception.room import RoomNotFoundException, RoomStatusException 10 | from reception.domain.entity.reservation import Reservation 11 | from reception.presentation.rest.request import CheckInRequest, CreateReservationRequest, UpdateGuestRequest 12 | from reception.presentation.rest.response import ReservationSchema, ReservationResponse 13 | from shared_kernel.presentation.response import BaseResponse 14 | from shared_kernel.infra.container import AppContainer 15 | 16 | router = APIRouter(prefix="/reception") 17 | 18 | 19 | @router.post( 20 | "/reservations", 21 | status_code=status.HTTP_201_CREATED, 22 | responses={ 23 | status.HTTP_201_CREATED: {"model": ReservationResponse}, 24 | status.HTTP_409_CONFLICT: {"model": BaseResponse}, 25 | } 26 | ) 27 | @inject 28 | def post_reservations( 29 | create_reservation_request: CreateReservationRequest = Body(), 30 | reservation_command: ReservationCommandUseCase = Depends(Provide[AppContainer.reception.reservation_command]), 31 | ) -> ReservationResponse: 32 | try: 33 | reservation: Reservation = reservation_command.make_reservation(request=create_reservation_request) 34 | except RoomNotFoundException as e: 35 | raise HTTPException( 36 | status_code=status.HTTP_404_NOT_FOUND, 37 | detail=e.message, 38 | ) 39 | except (RoomStatusException, ReservationStatusException) as e: 40 | raise HTTPException( 41 | status_code=status.HTTP_409_CONFLICT, 42 | detail=e.message, 43 | ) 44 | 45 | return ReservationResponse( 46 | detail="ok", 47 | result=ReservationSchema.build(reservation=reservation), 48 | ) 49 | 50 | 51 | @router.get( 52 | "/reservations/{reservation_number}", 53 | status_code=status.HTTP_200_OK, 54 | responses={ 55 | status.HTTP_200_OK: {"model": ReservationResponse}, 56 | status.HTTP_404_NOT_FOUND: {"model": BaseResponse}, 57 | } 58 | ) 59 | @inject 60 | def get_reservation( 61 | reservation_number: str, 62 | reservation_query: ReservationQueryUseCase = Depends(Provide[AppContainer.reception.reservation_query]), 63 | ) -> ReservationResponse: 64 | try: 65 | reservation: Reservation = reservation_query.get_reservation(reservation_number=reservation_number) 66 | except ReservationNotFoundException as e: 67 | raise HTTPException( 68 | status_code=status.HTTP_404_NOT_FOUND, 69 | detail=e.message, 70 | ) 71 | 72 | return ReservationResponse( 73 | detail="ok", 74 | result=ReservationSchema.build(reservation=reservation), 75 | ) 76 | 77 | 78 | @router.patch( 79 | "/reservations/{reservation_number}/guest", 80 | status_code=status.HTTP_200_OK, 81 | responses={ 82 | status.HTTP_200_OK: {"model": ReservationResponse}, 83 | status.HTTP_404_NOT_FOUND: {"model": BaseResponse}, 84 | status.HTTP_409_CONFLICT: {"model": BaseResponse}, 85 | } 86 | ) 87 | @inject 88 | def patch_reservation( 89 | reservation_number: str, 90 | update_quest_request: UpdateGuestRequest = Body(), 91 | reservation_command: ReservationCommandUseCase = Depends(Provide[AppContainer.reception.reservation_command]), 92 | ) -> ReservationResponse: 93 | try: 94 | reservation: Reservation = reservation_command.update_guest_info( 95 | reservation_number=reservation_number, request=update_quest_request 96 | ) 97 | except ReservationNotFoundException as e: 98 | raise HTTPException( 99 | status_code=status.HTTP_404_NOT_FOUND, 100 | detail=e.message, 101 | ) 102 | except (RoomStatusException, ReservationStatusException) as e: 103 | raise HTTPException( 104 | status_code=status.HTTP_409_CONFLICT, 105 | detail=e.message, 106 | ) 107 | 108 | return ReservationResponse( 109 | detail="ok", 110 | result=ReservationSchema.build(reservation=reservation), 111 | ) 112 | 113 | 114 | @router.post( 115 | "/reservations/{reservation_number}/check-in", 116 | status_code=status.HTTP_200_OK, 117 | responses={ 118 | status.HTTP_200_OK: {"model": ReservationResponse}, 119 | status.HTTP_400_BAD_REQUEST: {"model": BaseResponse}, 120 | status.HTTP_404_NOT_FOUND: {"model": BaseResponse}, 121 | status.HTTP_409_CONFLICT: {"model": BaseResponse}, 122 | } 123 | ) 124 | @inject 125 | def post_reservation_check_in( 126 | reservation_number: str, 127 | check_in_request: CheckInRequest = Body(), 128 | reservation_command: ReservationCommandUseCase = Depends(Provide[AppContainer.reception.reservation_command]), 129 | ) -> ReservationResponse: 130 | try: 131 | reservation: Reservation = reservation_command.check_in( 132 | reservation_number=reservation_number, mobile=check_in_request.mobile 133 | ) 134 | except (CheckInDateException, CheckInAuthenticationException) as e: 135 | raise HTTPException( 136 | status_code=status.HTTP_400_BAD_REQUEST, 137 | detail=e.message, 138 | ) 139 | except ReservationNotFoundException as e: 140 | raise HTTPException( 141 | status_code=status.HTTP_404_NOT_FOUND, 142 | detail=e.message, 143 | ) 144 | except (RoomStatusException, ReservationStatusException) as e: 145 | raise HTTPException( 146 | status_code=status.HTTP_409_CONFLICT, 147 | detail=e.message, 148 | ) 149 | 150 | return ReservationResponse( 151 | detail="ok", 152 | result=ReservationSchema.build(reservation=reservation), 153 | ) 154 | 155 | 156 | @router.post( 157 | "/reservations/{reservation_number}/check-out", 158 | status_code=status.HTTP_200_OK, 159 | responses={ 160 | status.HTTP_200_OK: {"model": ReservationResponse}, 161 | status.HTTP_404_NOT_FOUND: {"model": BaseResponse}, 162 | status.HTTP_409_CONFLICT: {"model": BaseResponse}, 163 | } 164 | ) 165 | @inject 166 | def post_reservation_check_out( 167 | reservation_number: str, 168 | reservation_command: ReservationCommandUseCase = Depends(Provide[AppContainer.reception.reservation_command]), 169 | ) -> ReservationResponse: 170 | try: 171 | reservation: Reservation = reservation_command.check_out(reservation_number=reservation_number) 172 | except ReservationNotFoundException as e: 173 | raise HTTPException( 174 | status_code=status.HTTP_404_NOT_FOUND, 175 | detail=e.message, 176 | ) 177 | except (RoomStatusException, ReservationStatusException) as e: 178 | raise HTTPException( 179 | status_code=status.HTTP_409_CONFLICT, 180 | detail=e.message, 181 | ) 182 | 183 | return ReservationResponse( 184 | detail="ok", 185 | result=ReservationSchema.build(reservation=reservation), 186 | ) 187 | 188 | 189 | @router.post("/reservations/{reservation_number}/cancel") 190 | @inject 191 | def post_reservation_cancel( 192 | reservation_number: str, 193 | reservation_command: ReservationCommandUseCase = Depends(Provide[AppContainer.reception.reservation_command]), 194 | ) -> ReservationResponse: 195 | try: 196 | reservation: Reservation = reservation_command.cancel(reservation_number=reservation_number) 197 | except ReservationNotFoundException as e: 198 | raise HTTPException( 199 | status_code=status.HTTP_404_NOT_FOUND, 200 | detail=e.message, 201 | ) 202 | except (RoomStatusException, ReservationStatusException) as e: 203 | raise HTTPException( 204 | status_code=status.HTTP_409_CONFLICT, 205 | detail=e.message, 206 | ) 207 | 208 | return ReservationResponse( 209 | detail="ok", 210 | result=ReservationSchema.build(reservation=reservation), 211 | ) 212 | -------------------------------------------------------------------------------- /src/reception/presentation/rest/request.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel 4 | 5 | from reception.domain.value_object.guest import mobile_type 6 | 7 | 8 | class CreateReservationRequest(BaseModel): 9 | room_number: str 10 | date_in: datetime 11 | date_out: datetime 12 | guest_mobile: mobile_type 13 | guest_name: str | None = None 14 | 15 | 16 | class UpdateGuestRequest(BaseModel): 17 | guest_mobile: mobile_type 18 | guest_name: str | None = None 19 | 20 | 21 | class CheckInRequest(BaseModel): 22 | mobile: mobile_type 23 | -------------------------------------------------------------------------------- /src/reception/presentation/rest/response.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | 5 | from pydantic import BaseModel 6 | 7 | from reception.domain.entity.reservation import Reservation 8 | from reception.domain.entity.room import Room 9 | from reception.domain.value_object.guest import Guest, mobile_type 10 | from shared_kernel.presentation.response import BaseResponse 11 | from shared_kernel.domain.value_object import ReservationStatus, RoomStatus 12 | 13 | 14 | class RoomSchema(BaseModel): 15 | number: str 16 | status: RoomStatus 17 | 18 | @classmethod 19 | def from_entity(cls, room: Room) -> RoomSchema: 20 | return cls( 21 | number=room.number, 22 | status=room.room_status, 23 | ) 24 | 25 | 26 | class GuestSchema(BaseModel): 27 | mobile: mobile_type 28 | name: str | None = None 29 | 30 | @classmethod 31 | def from_entity(cls, guest: Guest) -> GuestSchema: 32 | return cls( 33 | mobile=guest.mobile, 34 | name=guest.name, 35 | ) 36 | 37 | 38 | class ReservationSchema(BaseModel): 39 | room: RoomSchema 40 | reservation_number: str 41 | status: ReservationStatus 42 | date_in: datetime 43 | date_out: datetime 44 | guest: GuestSchema 45 | 46 | @classmethod 47 | def build(cls, reservation: Reservation) -> ReservationSchema: 48 | return cls( 49 | room=RoomSchema.from_entity(reservation.room), 50 | reservation_number=reservation.reservation_number.value, 51 | status=reservation.reservation_status.value, 52 | date_in=reservation.date_in, 53 | date_out=reservation.date_out, 54 | guest=GuestSchema.from_entity(reservation.guest), 55 | ) 56 | 57 | 58 | class ReservationResponse(BaseResponse): 59 | result: ReservationSchema 60 | -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.8.1 2 | anyio==3.6.2 3 | attrs==22.1.0 4 | cffi==1.15.1 5 | click==8.1.3 6 | cryptography==38.0.3 7 | dependency-injector==4.41.0 8 | exceptiongroup==1.0.0 9 | fastapi==0.85.1 10 | greenlet==1.1.3.post0 11 | h11==0.14.0 12 | httptools==0.5.0 13 | idna==3.4 14 | iniconfig==1.1.1 15 | isort==5.10.1 16 | Mako==1.2.3 17 | MarkupSafe==2.1.1 18 | packaging==21.3 19 | pluggy==1.0.0 20 | pycparser==2.21 21 | pydantic==1.10.2 22 | PyMySQL==1.0.2 23 | pyparsing==3.0.9 24 | pytest==7.2.0 25 | python-dotenv==0.21.0 26 | PyYAML==6.0 27 | six==1.16.0 28 | sniffio==1.3.0 29 | SQLAlchemy==1.4.42 30 | SQLAlchemy-Utils==0.38.3 31 | starlette==0.20.4 32 | tomli==2.0.1 33 | typing_extensions==4.4.0 34 | uvicorn==0.19.0 35 | uvloop==0.17.0 36 | watchfiles==0.18.0 37 | websockets==10.4 38 | requests 39 | pytest-mock 40 | schema -------------------------------------------------------------------------------- /src/shared_kernel/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/__init__.py -------------------------------------------------------------------------------- /src/shared_kernel/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/domain/__init__.py -------------------------------------------------------------------------------- /src/shared_kernel/domain/entity.py: -------------------------------------------------------------------------------- 1 | from dataclasses import field 2 | from typing import Any, TypeVar 3 | 4 | EntityType = TypeVar("EntityType", bound="Entity") 5 | 6 | 7 | class Entity: 8 | id: int = field(init=False) 9 | 10 | def __eq__(self, other: Any) -> bool: 11 | if isinstance(other, type(self)): 12 | return self.id == other.id 13 | return False 14 | 15 | def __hash__(self): 16 | return hash(self.id) 17 | 18 | 19 | class AggregateRoot(Entity): 20 | """ 21 | An entry point of aggregate. 22 | """ 23 | pass 24 | -------------------------------------------------------------------------------- /src/shared_kernel/domain/exception.py: -------------------------------------------------------------------------------- 1 | class ValueObjectEnumError(Exception): 2 | def __str__(self): 3 | return "Value Object got invalid value." 4 | 5 | 6 | class BaseMsgException(Exception): 7 | message: str 8 | 9 | def __str__(self): 10 | return self.message 11 | -------------------------------------------------------------------------------- /src/shared_kernel/domain/value_object.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, EnumMeta 2 | from typing import Any, TypeVar 3 | 4 | from shared_kernel.domain.exception import ValueObjectEnumError 5 | 6 | ValueObjectType = TypeVar("ValueObjectType", bound="ValueObject") 7 | 8 | 9 | class ValueObject: 10 | def __composite_values__(self): 11 | return self.value, 12 | 13 | @classmethod 14 | def from_value(cls, value: Any) -> ValueObjectType: 15 | if isinstance(cls, EnumMeta): 16 | for item in cls: 17 | if item.value == value: 18 | return item 19 | raise ValueObjectEnumError 20 | 21 | instance = cls(value=value) 22 | return instance 23 | 24 | 25 | class RoomStatus(ValueObject, str, Enum): 26 | AVAILABLE = "AVAILABLE" 27 | RESERVED = "RESERVED" 28 | OCCUPIED = "OCCUPIED" 29 | 30 | @property 31 | def is_available(self) -> bool: 32 | return self == RoomStatus.AVAILABLE 33 | 34 | @property 35 | def is_reserved(self) -> bool: 36 | return self == RoomStatus.RESERVED 37 | 38 | @property 39 | def is_occupied(self) -> bool: 40 | return self == RoomStatus.OCCUPIED 41 | 42 | 43 | class ReservationStatus(ValueObject, str, Enum): 44 | IN_PROGRESS = "IN-PROGRESS" 45 | CANCELLED = "CANCELLED" 46 | COMPLETE = "COMPLETE" 47 | 48 | @property 49 | def in_progress(self) -> bool: 50 | return self == ReservationStatus.IN_PROGRESS 51 | -------------------------------------------------------------------------------- /src/shared_kernel/infra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/infra/__init__.py -------------------------------------------------------------------------------- /src/shared_kernel/infra/container.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | 3 | from display.infra.container import DisplayContainer 4 | from reception.infra.container import ReceptionContainer 5 | 6 | 7 | class AppContainer(containers.DeclarativeContainer): 8 | wiring_config = containers.WiringConfiguration( 9 | modules=[ 10 | "display.presentation.rest.api", 11 | "reception.presentation.rest.api" 12 | ] 13 | ) 14 | 15 | display = providers.Container(DisplayContainer) 16 | reception = providers.Container(ReceptionContainer) 17 | -------------------------------------------------------------------------------- /src/shared_kernel/infra/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/infra/database/__init__.py -------------------------------------------------------------------------------- /src/shared_kernel/infra/database/connection.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.orm import sessionmaker 5 | from sqlalchemy_utils import create_database, database_exists 6 | 7 | from shared_kernel.infra.fastapi.config import settings 8 | 9 | 10 | def get_engine(): 11 | db_engine = create_engine(settings.SQLALCHEMY_DATABASE_URL, pool_pre_ping=True) 12 | 13 | if not database_exists(db_engine.url): 14 | create_database(db_engine.url) 15 | 16 | return db_engine 17 | 18 | 19 | engine = get_engine() 20 | SessionFactory = sessionmaker(autocommit=False, autoflush=False, expire_on_commit=False, bind=engine) 21 | 22 | 23 | @contextmanager 24 | def get_db_session(): 25 | db = SessionFactory() 26 | try: 27 | yield db 28 | finally: 29 | db.close() 30 | -------------------------------------------------------------------------------- /src/shared_kernel/infra/database/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /src/shared_kernel/infra/database/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/infra/database/migrations/__init__.py -------------------------------------------------------------------------------- /src/shared_kernel/infra/database/migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from alembic import context 4 | from sqlalchemy import engine_from_config, pool 5 | 6 | # this is the Alembic Config object, which provides 7 | # access to the values within the .ini file in use. 8 | from shared_kernel.infra.database.orm import metadata 9 | from shared_kernel.infra.fastapi.config import settings 10 | 11 | config = context.config 12 | 13 | # Interpret the config file for Python logging. 14 | # This line sets up loggers basically. 15 | if config.config_file_name is not None: 16 | fileConfig(config.config_file_name) 17 | 18 | # add your model's MetaData object here 19 | # for 'autogenerate' support 20 | # from myapp import mymodel 21 | # target_metadata = mymodel.Base.metadata 22 | target_metadata = metadata 23 | 24 | # other values from the config, defined by the needs of env.py, 25 | # can be acquired: 26 | # my_important_option = config.get_main_option("my_important_option") 27 | # ... etc. 28 | 29 | 30 | database_url = settings.SQLALCHEMY_DATABASE_URL 31 | 32 | 33 | def run_migrations_offline() -> None: 34 | """Run migrations in 'offline' mode. 35 | 36 | This configures the context with just a URL 37 | and not an Engine, though an Engine is acceptable 38 | here as well. By skipping the Engine creation 39 | we don't even need a DBAPI to be available. 40 | 41 | Calls to context.execute() here emit the given string to the 42 | script output. 43 | 44 | """ 45 | context.configure( 46 | url=database_url, 47 | target_metadata=target_metadata, 48 | literal_binds=True, 49 | dialect_opts={"paramstyle": "named"}, 50 | ) 51 | 52 | with context.begin_transaction(): 53 | context.run_migrations() 54 | 55 | 56 | def run_migrations_online() -> None: 57 | """Run migrations in 'online' mode. 58 | 59 | In this scenario we need to create an Engine 60 | and associate a connection with the context. 61 | 62 | """ 63 | configuration = config.get_section(config.config_ini_section) 64 | configuration["sqlalchemy.url"] = database_url 65 | connectable = engine_from_config( 66 | configuration, 67 | prefix="sqlalchemy.", 68 | poolclass=pool.NullPool, 69 | ) 70 | 71 | with connectable.connect() as connection: 72 | context.configure( 73 | connection=connection, target_metadata=target_metadata 74 | ) 75 | 76 | with context.begin_transaction(): 77 | context.run_migrations() 78 | 79 | 80 | if context.is_offline_mode(): 81 | run_migrations_offline() 82 | else: 83 | run_migrations_online() 84 | -------------------------------------------------------------------------------- /src/shared_kernel/infra/database/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /src/shared_kernel/infra/database/migrations/versions/6b595c7689ad_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: 6b595c7689ad 4 | Revises: 5 | Create Date: 2022-11-05 09:16:27.850505 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = '6b595c7689ad' 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade() -> None: 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table('hotel_room', 21 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), 22 | sa.Column('number', sa.String(length=20), nullable=False), 23 | sa.Column('status', sa.String(length=20), nullable=False), 24 | sa.Column('image_url', sa.String(length=200), nullable=False), 25 | sa.Column('description', sa.Text(), nullable=True), 26 | sa.PrimaryKeyConstraint('id'), 27 | sa.UniqueConstraint('number', name='uix_hotel_room_number') 28 | ) 29 | op.create_table('room_reservation', 30 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), 31 | sa.Column('room_id', sa.Integer(), nullable=False), 32 | sa.Column('number', sa.String(length=20), nullable=False), 33 | sa.Column('status', sa.String(length=20), nullable=False), 34 | sa.Column('date_in', sa.DateTime(timezone=True), nullable=True), 35 | sa.Column('date_out', sa.DateTime(timezone=True), nullable=True), 36 | sa.Column('guest_mobile', sa.String(length=20), nullable=False), 37 | sa.Column('guest_name', sa.String(length=50), nullable=True), 38 | sa.ForeignKeyConstraint(['room_id'], ['hotel_room.id'], ), 39 | sa.PrimaryKeyConstraint('id') 40 | ) 41 | # ### end Alembic commands ### 42 | 43 | 44 | def downgrade() -> None: 45 | # ### commands auto generated by Alembic - please adjust! ### 46 | op.drop_table('room_reservation') 47 | op.drop_table('hotel_room') 48 | # ### end Alembic commands ### 49 | -------------------------------------------------------------------------------- /src/shared_kernel/infra/database/migrations/versions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/infra/database/migrations/versions/__init__.py -------------------------------------------------------------------------------- /src/shared_kernel/infra/database/orm.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, DateTime, ForeignKey, Integer, MetaData, String, Table, Text, UniqueConstraint 2 | from sqlalchemy.orm import composite, registry, relationship 3 | 4 | from reception.domain.entity.room import Room 5 | from reception.domain.value_object.guest import Guest 6 | from reception.domain.value_object.reservation import ReservationNumber 7 | from shared_kernel.domain.value_object import ReservationStatus, RoomStatus 8 | 9 | metadata = MetaData() 10 | mapper_registry = registry() 11 | 12 | 13 | room_table = Table( 14 | "hotel_room", 15 | metadata, 16 | Column("id", Integer, primary_key=True, autoincrement=True), 17 | Column("number", String(20), nullable=False), 18 | Column("status", String(20), nullable=False), 19 | Column("image_url", String(200), nullable=False), 20 | Column("description", Text, nullable=True), 21 | UniqueConstraint("number", name="uix_hotel_room_number"), 22 | ) 23 | 24 | reservation_table = Table( 25 | "room_reservation", 26 | metadata, 27 | Column("id", Integer, primary_key=True, autoincrement=True), 28 | Column("room_id", Integer, ForeignKey("hotel_room.id"), nullable=False), 29 | Column("number", String(20), nullable=False), 30 | Column("status", String(20), nullable=False), 31 | Column("date_in", DateTime(timezone=True)), 32 | Column("date_out", DateTime(timezone=True)), 33 | Column("guest_mobile", String(20), nullable=False), 34 | Column("guest_name", String(50), nullable=True), 35 | ) 36 | 37 | 38 | def init_orm_mappers(): 39 | """ 40 | initialize orm mappings 41 | """ 42 | from reception.domain.entity.reservation import Reservation as ReceptionReservationEntity 43 | from reception.domain.entity.room import Room as ReceptionRoomEntity 44 | 45 | mapper_registry.map_imperatively( 46 | ReceptionRoomEntity, 47 | room_table, 48 | properties={ 49 | "room_status": composite(RoomStatus.from_value, room_table.c.status), 50 | } 51 | ) 52 | mapper_registry.map_imperatively( 53 | ReceptionReservationEntity, 54 | reservation_table, 55 | properties={ 56 | "room": relationship(Room, backref="reservations", order_by=reservation_table.c.id.desc, lazy="joined"), 57 | "reservation_number": composite(ReservationNumber.from_value, reservation_table.c.number), 58 | "reservation_status": composite(ReservationStatus.from_value, reservation_table.c.status), 59 | "guest": composite(Guest, reservation_table.c.guest_mobile, reservation_table.c.guest_name), 60 | } 61 | ) 62 | 63 | from display.domain.entity.room import Room as DisplayRoomEntity 64 | 65 | mapper_registry.map_imperatively( 66 | DisplayRoomEntity, 67 | room_table, 68 | properties={ 69 | "room_status": composite(RoomStatus.from_value, room_table.c.status), 70 | } 71 | ) 72 | -------------------------------------------------------------------------------- /src/shared_kernel/infra/database/repository.py: -------------------------------------------------------------------------------- 1 | from shared_kernel.domain.entity import EntityType 2 | 3 | 4 | class RDBRepository: 5 | @staticmethod 6 | def add(session, instance: EntityType): 7 | return session.add(instance) 8 | 9 | @staticmethod 10 | def commit(session): 11 | return session.commit() 12 | 13 | 14 | class RDBReadRepository: 15 | pass 16 | -------------------------------------------------------------------------------- /src/shared_kernel/infra/fastapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/infra/fastapi/__init__.py -------------------------------------------------------------------------------- /src/shared_kernel/infra/fastapi/config.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import BaseSettings 4 | 5 | 6 | class Settings(BaseSettings): 7 | DRIVER: ClassVar[str] = "mysql+pymysql" 8 | USERNAME: ClassVar[str] = "admin" 9 | PASSWORD: ClassVar[str] = "ddd-hotel" 10 | HOST: ClassVar[str] = "127.0.0.1" 11 | PORT: ClassVar[str] = 3306 12 | DATABASE: ClassVar[str] = "ddd-hotel" 13 | 14 | SQLALCHEMY_DATABASE_URL: ClassVar[str] = f"{DRIVER}://{USERNAME}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}" 15 | 16 | class Config: 17 | env_file = ".env" 18 | 19 | 20 | settings = Settings() 21 | -------------------------------------------------------------------------------- /src/shared_kernel/infra/fastapi/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from display.presentation.rest import api as display_api 4 | from reception.presentation.rest import api as reception_api 5 | from shared_kernel.infra.container import AppContainer 6 | from shared_kernel.infra.database.orm import init_orm_mappers 7 | 8 | app_container = AppContainer() 9 | 10 | app = FastAPI( 11 | title="Python-DDD-Hotel", 12 | contact={ 13 | "name": "qu3vipon", 14 | "email": "qu3vipon@gmail.com", 15 | }, 16 | ) 17 | 18 | app.container = app_container 19 | app.include_router(reception_api.router) 20 | app.include_router(display_api.router) 21 | 22 | init_orm_mappers() 23 | 24 | 25 | @app.get("/") 26 | def health_check(): 27 | return {"ping": "pong"} 28 | -------------------------------------------------------------------------------- /src/shared_kernel/infra/log/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/infra/log/__init__.py -------------------------------------------------------------------------------- /src/shared_kernel/presentation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/presentation/__init__.py -------------------------------------------------------------------------------- /src/shared_kernel/presentation/response.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class BaseResponse(BaseModel): 7 | detail: str = Field(...) 8 | result: Any 9 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/tests/__init__.py -------------------------------------------------------------------------------- /src/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date 2 | 3 | import pytest 4 | from fastapi.testclient import TestClient 5 | from schema import And, Use 6 | 7 | from shared_kernel.infra.fastapi.main import app 8 | 9 | 10 | @pytest.fixture 11 | def client(): 12 | return TestClient(app) 13 | 14 | 15 | valid_datetime = And(Use(lambda s: datetime.strptime(s, "%Y-%m-%dT%H:%M:%S")), datetime) 16 | valid_date = And(Use(lambda s: datetime.strptime(s, "%Y-%m-%d").date()), date) 17 | -------------------------------------------------------------------------------- /src/tests/functional/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/tests/functional/__init__.py -------------------------------------------------------------------------------- /src/tests/functional/test_display.py: -------------------------------------------------------------------------------- 1 | from schema import Schema, Or 2 | 3 | from display.domain.entity.room import Room 4 | from shared_kernel.domain.value_object import RoomStatus 5 | 6 | 7 | def test_get_rooms(client, mocker): 8 | # given 9 | room_available = Room(number="A", room_status=RoomStatus.AVAILABLE, image_url="img1") 10 | room_available.id = 1 # Assume that it is allocated from db 11 | 12 | display_query = mocker.MagicMock() 13 | display_query.get_rooms.return_value = [room_available] 14 | 15 | with client.app.container.display.query.override(display_query): 16 | # when 17 | response = client.get("/display/rooms", params={"status": RoomStatus.AVAILABLE}) 18 | 19 | # then 20 | display_query.get_rooms.assert_called_once_with(room_status=RoomStatus.AVAILABLE) 21 | 22 | schema = Schema( 23 | { 24 | "detail": "ok", 25 | "result": [ 26 | { 27 | "id": 1, 28 | "number": "A", 29 | "status": RoomStatus.AVAILABLE, 30 | "image_url": "img1", 31 | "description": Or(str, None), 32 | } 33 | ] 34 | } 35 | ) 36 | 37 | assert schema.is_valid(response.json()) 38 | -------------------------------------------------------------------------------- /src/tests/functional/test_reception.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from schema import Schema 4 | 5 | from reception.domain.entity.reservation import Reservation 6 | from reception.domain.entity.room import Room 7 | from reception.domain.value_object.guest import Guest 8 | from reception.domain.value_object.reservation import ReservationNumber 9 | from shared_kernel.domain.value_object import RoomStatus, ReservationStatus 10 | 11 | 12 | def test_create_reservation(client, mocker): 13 | # given 14 | ROOM_NUMBER = "ROOM-A" 15 | GUEST_MOBILE = "+82-10-1111-2222" 16 | GUEST_NAME = "Guido" 17 | 18 | new_reservation = Reservation( 19 | room=Room( 20 | number=ROOM_NUMBER, 21 | room_status=RoomStatus.AVAILABLE, 22 | ), 23 | reservation_number=ReservationNumber.generate(), 24 | reservation_status=ReservationStatus.IN_PROGRESS, 25 | date_in=datetime(2023, 4, 1), 26 | date_out=datetime(2023, 4, 2), 27 | guest=Guest( 28 | mobile=GUEST_MOBILE, 29 | name=GUEST_NAME, 30 | ), 31 | ) 32 | 33 | reservation_cmd = mocker.MagicMock() 34 | reservation_cmd.make_reservation.return_value = new_reservation 35 | with client.app.container.reception.reservation_command.override(reservation_cmd): 36 | # when 37 | response = client.post( 38 | "/reception/reservations", 39 | json={ 40 | "room_number": ROOM_NUMBER, 41 | "date_in": "2023-04-01T00:00:00", 42 | "date_out": "2023-04-02T00:00:00", 43 | "guest_mobile": GUEST_MOBILE, 44 | "guest_name": GUEST_NAME, 45 | } 46 | ) 47 | 48 | # then 49 | schema = Schema( 50 | { 51 | "detail": "ok", 52 | "result": { 53 | "room": { 54 | "number": ROOM_NUMBER, 55 | "status": RoomStatus.AVAILABLE, 56 | }, 57 | "reservation_number": new_reservation.reservation_number.value, 58 | "status": ReservationStatus.IN_PROGRESS, 59 | "date_in": "2023-04-01T00:00:00", 60 | "date_out": "2023-04-02T00:00:00", 61 | "guest": { 62 | "mobile": GUEST_MOBILE, 63 | "name": GUEST_NAME, 64 | } 65 | } 66 | } 67 | ) 68 | assert response.status_code == 201 69 | assert schema.validate(response.json()) 70 | 71 | 72 | # get reservation 73 | 74 | # update guest info 75 | 76 | # check in 77 | 78 | # check out 79 | 80 | # cancel -------------------------------------------------------------------------------- /src/tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/tests/integration/__init__.py -------------------------------------------------------------------------------- /src/tests/integration/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/tests/integration/database/__init__.py -------------------------------------------------------------------------------- /src/tests/integration/database/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import create_engine, event 3 | from sqlalchemy.orm import sessionmaker 4 | from sqlalchemy_utils import database_exists, create_database 5 | 6 | from shared_kernel.infra.database.orm import metadata 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def test_db(): 11 | test_db_url = "mysql+pymysql://admin:ddd-hotel@127.0.0.1:3306/ddd-hotel" 12 | if not database_exists(test_db_url): 13 | create_database(test_db_url) 14 | 15 | engine = create_engine(test_db_url) 16 | metadata.create_all(engine) 17 | try: 18 | yield engine 19 | finally: 20 | metadata.drop_all(engine) 21 | 22 | 23 | @pytest.fixture(scope="function") 24 | def test_session(test_db): 25 | connection = test_db.connect() 26 | 27 | trans = connection.begin() 28 | session = sessionmaker()(bind=connection) 29 | 30 | session.begin_nested() # SAVEPOINT 31 | 32 | @event.listens_for(session, "after_transaction_end") 33 | def restart_savepoint(session, transaction): 34 | """ 35 | Each time that SAVEPOINT ends, reopen it 36 | """ 37 | if transaction.nested and not transaction._parent.nested: 38 | session.begin_nested() 39 | 40 | yield session 41 | 42 | session.close() 43 | trans.rollback() # roll back to the SAVEPOINT 44 | connection.close() 45 | 46 | 47 | @pytest.fixture 48 | def room_display(test_session): 49 | from display.domain.entity.room import Room 50 | from shared_kernel.domain.value_object import RoomStatus 51 | 52 | room = Room(number="New", room_status=RoomStatus.AVAILABLE, image_url="image_url") 53 | test_session.add(room) 54 | test_session.commit() 55 | return room 56 | -------------------------------------------------------------------------------- /src/tests/integration/database/test_display.py: -------------------------------------------------------------------------------- 1 | from display.domain.entity.room import Room 2 | from shared_kernel.domain.value_object import RoomStatus 3 | 4 | 5 | def test_save_room(test_session): 6 | # given 7 | assert not test_session.query(Room).first() 8 | 9 | new_room = Room(number="New", room_status=RoomStatus.AVAILABLE, image_url="image_url") 10 | 11 | # when 12 | test_session.add(new_room) 13 | test_session.commit() 14 | 15 | # then 16 | assert test_session.query(Room).filter( 17 | Room.number == "New", 18 | Room.room_status == RoomStatus.AVAILABLE, 19 | Room.image_url == "image_url", 20 | ).first() 21 | -------------------------------------------------------------------------------- /src/tests/integration/database/test_reception.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from reception.domain.entity.reservation import Reservation 4 | from reception.domain.entity.room import Room 5 | from reception.domain.value_object.guest import Guest 6 | 7 | 8 | def test_make_reservation(test_session, room_display): 9 | # given 10 | room = test_session.query(Room).first() 11 | 12 | # when 13 | new_reservation = Reservation.make( 14 | room=room, 15 | date_in=datetime.datetime(2023, 4, 1), 16 | date_out=datetime.datetime(2023, 4, 2), 17 | guest=Guest( 18 | mobile="+82-10-1111-2222", 19 | name="Guido", 20 | ) 21 | ) 22 | 23 | test_session.add(new_reservation) 24 | test_session.commit() 25 | 26 | # then 27 | assert test_session.query(Reservation).filter( 28 | Reservation.room == room 29 | ).first() 30 | -------------------------------------------------------------------------------- /src/tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/tests/unit/__init__.py --------------------------------------------------------------------------------