Prerequisites
- Basic understanding of programming concepts ๐
- Python installation (3.8+) ๐
- VS Code or preferred IDE ๐ป
What you'll learn
- Understand the concept fundamentals ๐ฏ
- Apply the concept in real projects ๐๏ธ
- Debug common issues ๐
- Write clean, Pythonic code โจ
๐ฏ Introduction
Welcome to this exciting tutorial on Domain-Driven Design (DDD) in Python! ๐ In this guide, weโll explore how to build software that truly reflects your business domain.
Youโll discover how DDD can transform your Python development experience. Whether youโre building microservices ๐, enterprise applications ๐ฅ๏ธ, or complex business systems ๐, understanding DDD is essential for writing robust, maintainable code that speaks your business language.
By the end of this tutorial, youโll feel confident implementing DDD patterns in your own projects! Letโs dive in! ๐โโ๏ธ
๐ Understanding Domain-Driven Design
๐ค What is Domain-Driven Design?
Domain-Driven Design is like building a house with blueprints that everyone can understand ๐๏ธ. Think of it as creating software that speaks the same language as your business experts - no translation needed!
In Python terms, DDD is a strategic approach to software design that focuses on modeling software to match a domain. This means you can:
- โจ Create code that business experts can understand
- ๐ Build systems that evolve with business needs
- ๐ก๏ธ Protect business logic from technical concerns
๐ก Why Use DDD?
Hereโs why developers love DDD:
- Ubiquitous Language ๐ฃ๏ธ: Everyone speaks the same language
- Bounded Contexts ๐ฆ: Clear boundaries between different parts
- Rich Domain Models ๐: Business logic lives where it belongs
- Event-Driven Architecture ๐ฏ: Systems that react to business events
Real-world example: Imagine building an e-commerce platform ๐. With DDD, your code would use terms like โOrderโ, โCustomerโ, and โInventoryโ - exactly what the business uses!
๐ง Basic Syntax and Usage
๐ Simple Example: Building Blocks
Letโs start with DDDโs core building blocks:
# ๐ Hello, DDD!
from dataclasses import dataclass
from typing import List, Optional
from datetime import datetime
import uuid
# ๐จ Value Object - Immutable and defined by its attributes
@dataclass(frozen=True)
class Money:
amount: float
currency: str = "USD" # ๐ต Default currency
def __add__(self, other: 'Money') -> 'Money':
if self.currency != other.currency:
raise ValueError("Cannot add different currencies! ๐")
return Money(self.amount + other.amount, self.currency)
# ๐๏ธ Entity - Has identity and lifecycle
class Product:
def __init__(self, name: str, price: Money):
self.id = str(uuid.uuid4()) # ๐ Unique identity
self.name = name
self.price = price
self.stock = 0
def add_stock(self, quantity: int):
"""๐ฆ Add items to inventory"""
self.stock += quantity
print(f"Added {quantity} {self.name}(s) to stock! ๐")
๐ก Explanation: Notice how we use domain terms directly! Money
is a value object (immutable), while Product
is an entity (has identity).
๐ฏ Domain Events
Hereโs how we capture things that happen in our domain:
# ๐ฏ Domain Event - Something that happened
@dataclass
class ProductPurchased:
product_id: str
quantity: int
price: Money
purchased_at: datetime
customer_id: str
def __str__(self):
return f"๐๏ธ Customer {self.customer_id} bought {self.quantity} items!"
# ๐ Aggregate - Consistency boundary
class ShoppingCart:
def __init__(self, customer_id: str):
self.id = str(uuid.uuid4())
self.customer_id = customer_id
self.items: List[tuple[Product, int]] = []
self.events: List[ProductPurchased] = [] # ๐ Event sourcing!
def add_item(self, product: Product, quantity: int):
"""๐ Add product to cart"""
if product.stock < quantity:
raise ValueError(f"Not enough stock! Only {product.stock} available ๐ข")
self.items.append((product, quantity))
print(f"Added {quantity} {product.name}(s) to cart! โจ")
def checkout(self) -> List[ProductPurchased]:
"""๐ณ Complete the purchase"""
events = []
for product, quantity in self.items:
event = ProductPurchased(
product_id=product.id,
quantity=quantity,
price=Money(product.price.amount * quantity),
purchased_at=datetime.now(),
customer_id=self.customer_id
)
events.append(event)
product.stock -= quantity # ๐ Update inventory
self.events.extend(events)
self.items.clear() # ๐งน Empty the cart
return events
๐ก Practical Examples
๐ฆ Example 1: Banking Domain
Letโs build a simple banking system:
# ๐ฐ Banking domain model
from enum import Enum
from decimal import Decimal
class AccountType(Enum):
CHECKING = "checking"
SAVINGS = "savings"
@dataclass(frozen=True)
class AccountNumber:
"""๐ข Value object for account numbers"""
value: str
def __post_init__(self):
if not self.value or len(self.value) != 10:
raise ValueError("Account number must be 10 digits! ๐ซ")
class BankAccount:
"""๐ฆ Bank account aggregate"""
def __init__(self, account_number: AccountNumber, account_type: AccountType):
self.account_number = account_number
self.account_type = account_type
self.balance = Decimal("0.00")
self.is_active = True
self.transactions: List[Transaction] = []
def deposit(self, amount: Decimal) -> 'Transaction':
"""๐ต Deposit money"""
if amount <= 0:
raise ValueError("Amount must be positive! ๐ธ")
if not self.is_active:
raise ValueError("Account is closed! ๐")
self.balance += amount
transaction = Transaction(
account_number=self.account_number,
amount=amount,
transaction_type="DEPOSIT",
balance_after=self.balance
)
self.transactions.append(transaction)
print(f"โ
Deposited ${amount}! New balance: ${self.balance}")
return transaction
def withdraw(self, amount: Decimal) -> 'Transaction':
"""๐ธ Withdraw money"""
if amount > self.balance:
raise ValueError(f"Insufficient funds! Only ${self.balance} available ๐
")
self.balance -= amount
transaction = Transaction(
account_number=self.account_number,
amount=amount,
transaction_type="WITHDRAWAL",
balance_after=self.balance
)
self.transactions.append(transaction)
print(f"๐ณ Withdrew ${amount}! Remaining: ${self.balance}")
return transaction
@dataclass
class Transaction:
"""๐ Domain event for transactions"""
account_number: AccountNumber
amount: Decimal
transaction_type: str
balance_after: Decimal
timestamp: datetime = datetime.now()
# ๐ฎ Let's use our banking domain!
account = BankAccount(
AccountNumber("1234567890"),
AccountType.CHECKING
)
account.deposit(Decimal("1000.00"))
account.withdraw(Decimal("50.00"))
๐ฏ Try it yourself: Add a transfer
method that moves money between accounts!
๐ Example 2: Ride-Sharing Domain
Letโs model a ride-sharing service:
# ๐ Ride-sharing domain
from geopy.distance import geodesic
@dataclass(frozen=True)
class Location:
"""๐ Value object for locations"""
latitude: float
longitude: float
address: str
def distance_to(self, other: 'Location') -> float:
"""๐ Calculate distance in kilometers"""
return geodesic(
(self.latitude, self.longitude),
(other.latitude, other.longitude)
).km
class RideStatus(Enum):
REQUESTED = "๐ต Requested"
DRIVER_ASSIGNED = "๐ก Driver Assigned"
IN_PROGRESS = "๐ข In Progress"
COMPLETED = "โ
Completed"
CANCELLED = "โ Cancelled"
class Ride:
"""๐ Ride aggregate"""
def __init__(self, rider_id: str, pickup: Location, destination: Location):
self.id = str(uuid.uuid4())
self.rider_id = rider_id
self.driver_id: Optional[str] = None
self.pickup = pickup
self.destination = destination
self.status = RideStatus.REQUESTED
self.price: Optional[Money] = None
self.events: List[dict] = []
# ๐ฐ Calculate estimated price
distance = pickup.distance_to(destination)
self.estimated_price = Money(
amount=round(5.0 + (distance * 2.5), 2) # Base + per km
)
self._record_event("RideRequested", {
"pickup": pickup.address,
"destination": destination.address,
"estimated_price": self.estimated_price.amount
})
def assign_driver(self, driver_id: str):
"""๐ Assign a driver to the ride"""
if self.status != RideStatus.REQUESTED:
raise ValueError(f"Cannot assign driver in {self.status.value} status! ๐ซ")
self.driver_id = driver_id
self.status = RideStatus.DRIVER_ASSIGNED
self._record_event("DriverAssigned", {"driver_id": driver_id})
print(f"๐ฏ Driver {driver_id} assigned to ride!")
def start_ride(self):
"""๐ Start the ride"""
if self.status != RideStatus.DRIVER_ASSIGNED:
raise ValueError("No driver assigned yet! ๐คท")
self.status = RideStatus.IN_PROGRESS
self._record_event("RideStarted", {"start_time": datetime.now()})
print(f"๐ Ride started! Heading to {self.destination.address}")
def complete_ride(self, actual_distance: float):
"""๐ Complete the ride"""
if self.status != RideStatus.IN_PROGRESS:
raise ValueError("Ride not in progress! ๐")
# ๐ต Calculate final price based on actual distance
self.price = Money(amount=round(5.0 + (actual_distance * 2.5), 2))
self.status = RideStatus.COMPLETED
self._record_event("RideCompleted", {
"final_price": self.price.amount,
"distance": actual_distance
})
print(f"โ
Ride completed! Total: ${self.price.amount}")
def _record_event(self, event_type: str, data: dict):
"""๐ Record domain events"""
self.events.append({
"type": event_type,
"timestamp": datetime.now(),
"data": data
})
# ๐ฎ Test our ride-sharing domain!
pickup = Location(40.7128, -74.0060, "Times Square, NYC ๐ฝ")
destination = Location(40.7614, -73.9776, "Central Park, NYC ๐ณ")
ride = Ride("customer123", pickup, destination)
print(f"๐ฐ Estimated price: ${ride.estimated_price.amount}")
ride.assign_driver("driver456")
ride.start_ride()
ride.complete_ride(actual_distance=3.2)
๐ Advanced Concepts
๐งโโ๏ธ Repository Pattern
When youโre ready to level up, implement the repository pattern:
# ๐ฏ Repository pattern for data access
from abc import ABC, abstractmethod
from typing import Dict, Optional, List
class Repository(ABC):
"""๐ฆ Abstract repository interface"""
@abstractmethod
def add(self, entity):
pass
@abstractmethod
def get(self, entity_id: str):
pass
@abstractmethod
def update(self, entity):
pass
@abstractmethod
def delete(self, entity_id: str):
pass
class InMemoryProductRepository(Repository):
"""๐พ In-memory implementation for testing"""
def __init__(self):
self.storage: Dict[str, Product] = {}
def add(self, product: Product):
"""โ Add product to repository"""
self.storage[product.id] = product
print(f"๐พ Saved product: {product.name}")
def get(self, product_id: str) -> Optional[Product]:
"""๐ Find product by ID"""
return self.storage.get(product_id)
def update(self, product: Product):
"""๐ Update existing product"""
if product.id not in self.storage:
raise ValueError(f"Product {product.id} not found! ๐ฑ")
self.storage[product.id] = product
def delete(self, product_id: str):
"""๐๏ธ Remove product"""
if product_id in self.storage:
del self.storage[product_id]
print(f"๐๏ธ Deleted product {product_id}")
def find_by_name(self, name: str) -> List[Product]:
"""๐ Custom query method"""
return [p for p in self.storage.values() if name.lower() in p.name.lower()]
๐๏ธ Domain Services
For complex business logic that doesnโt belong to a single entity:
# ๐ Domain service for complex operations
class PricingService:
"""๐ฐ Service for pricing calculations"""
def __init__(self, discount_repository: Repository):
self.discount_repository = discount_repository
def calculate_total(self, items: List[tuple[Product, int]],
customer_type: str = "regular") -> Money:
"""๐งฎ Calculate total with discounts"""
subtotal = Money(0.0)
for product, quantity in items:
item_total = Money(product.price.amount * quantity)
subtotal = subtotal + item_total
# ๐ Apply customer discount
discount_rate = self._get_discount_rate(customer_type)
if discount_rate > 0:
discount_amount = Money(subtotal.amount * discount_rate)
total = Money(subtotal.amount - discount_amount.amount)
print(f"๐ Applied {discount_rate*100}% discount!")
return total
return subtotal
def _get_discount_rate(self, customer_type: str) -> float:
"""๐ท๏ธ Get discount rate by customer type"""
discounts = {
"regular": 0.0,
"premium": 0.1, # 10% off
"vip": 0.2 # 20% off
}
return discounts.get(customer_type, 0.0)
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: Anemic Domain Models
# โ Wrong way - no behavior, just data!
class Order:
def __init__(self):
self.id = None
self.items = []
self.total = 0.0
self.status = "pending"
# โ
Correct way - rich domain model with behavior!
class Order:
def __init__(self, customer_id: str):
self.id = str(uuid.uuid4())
self.customer_id = customer_id
self.items: List[OrderItem] = []
self.status = OrderStatus.PENDING
def add_item(self, product: Product, quantity: int):
"""๐ Business logic lives here!"""
if self.status != OrderStatus.PENDING:
raise ValueError("Cannot modify confirmed order! ๐")
# More business rules...
def calculate_total(self) -> Money:
"""๐ฐ Order knows how to calculate its total"""
return sum(item.subtotal() for item in self.items)
๐คฏ Pitfall 2: Mixing Infrastructure with Domain
# โ Dangerous - domain depends on infrastructure!
class Product:
def save_to_database(self):
import sqlite3 # ๐ฅ Domain shouldn't know about DB!
conn = sqlite3.connect('products.db')
# ...
# โ
Safe - keep infrastructure separate!
class Product:
"""Pure domain model"""
def update_price(self, new_price: Money):
if new_price.amount < 0:
raise ValueError("Price cannot be negative! ๐ธ")
self.price = new_price
# Infrastructure layer handles persistence
class SQLProductRepository(Repository):
def add(self, product: Product):
# Database logic here
pass
๐ ๏ธ Best Practices
- ๐ฏ Ubiquitous Language: Use the same terms as your domain experts
- ๐ฆ Bounded Contexts: Keep related concepts together, separate others
- ๐ก๏ธ Protect Invariants: Aggregates enforce business rules
- ๐จ Rich Domain Models: Put behavior where the data is
- โจ Event Sourcing: Consider storing events instead of state
๐งช Hands-On Exercise
๐ฏ Challenge: Build a Hotel Booking System
Create a DDD-based hotel booking system:
๐ Requirements:
- โ Rooms with different types (single, double, suite)
- ๐ท๏ธ Dynamic pricing based on season and occupancy
- ๐ค Guest profiles with loyalty levels
- ๐ Booking with check-in/check-out dates
- ๐จ Each room type needs special amenities!
๐ Bonus Points:
- Add event sourcing for bookings
- Implement overbooking protection
- Create a pricing strategy pattern
๐ก Solution
๐ Click to see solution
# ๐ฏ Hotel booking domain model!
from enum import Enum
from datetime import date, timedelta
class RoomType(Enum):
SINGLE = ("single", "๐๏ธ", 100.0)
DOUBLE = ("double", "๐๏ธ๐๏ธ", 150.0)
SUITE = ("suite", "๐ฐ", 300.0)
def __init__(self, value, emoji, base_price):
self._value_ = value
self.emoji = emoji
self.base_price = base_price
class LoyaltyLevel(Enum):
BRONZE = ("bronze", "๐ฅ", 0.05)
SILVER = ("silver", "๐ฅ", 0.10)
GOLD = ("gold", "๐ฅ", 0.15)
def __init__(self, value, emoji, discount):
self._value_ = value
self.emoji = emoji
self.discount = discount
@dataclass(frozen=True)
class DateRange:
"""๐
Value object for date ranges"""
check_in: date
check_out: date
def __post_init__(self):
if self.check_out <= self.check_in:
raise ValueError("Check-out must be after check-in! ๐")
@property
def nights(self) -> int:
return (self.check_out - self.check_in).days
def overlaps_with(self, other: 'DateRange') -> bool:
"""๐ Check if date ranges overlap"""
return not (self.check_out <= other.check_in or
self.check_in >= other.check_out)
class Room:
"""๐จ Room entity"""
def __init__(self, room_number: str, room_type: RoomType):
self.room_number = room_number
self.room_type = room_type
self.bookings: List['Booking'] = []
self.amenities = self._get_amenities()
def _get_amenities(self) -> List[str]:
"""โจ Get amenities by room type"""
amenities_map = {
RoomType.SINGLE: ["๐บ TV", "โ Coffee maker"],
RoomType.DOUBLE: ["๐บ TV", "โ Coffee maker", "๐ง Mini-bar"],
RoomType.SUITE: ["๐บ TV", "โ Coffee maker", "๐ง Mini-bar",
"๐ Jacuzzi", "๐พ Champagne"]
}
return amenities_map[self.room_type]
def is_available(self, date_range: DateRange) -> bool:
"""๐ Check if room is available"""
for booking in self.bookings:
if booking.status == BookingStatus.CONFIRMED and \
booking.date_range.overlaps_with(date_range):
return False
return True
class BookingStatus(Enum):
PENDING = "๐ต Pending"
CONFIRMED = "โ
Confirmed"
CANCELLED = "โ Cancelled"
CHECKED_IN = "๐จ Checked In"
CHECKED_OUT = "๐ Checked Out"
class Booking:
"""๐ Booking aggregate"""
def __init__(self, guest_id: str, room: Room, date_range: DateRange):
if not room.is_available(date_range):
raise ValueError(f"Room {room.room_number} not available! ๐ข")
self.id = str(uuid.uuid4())
self.guest_id = guest_id
self.room = room
self.date_range = date_range
self.status = BookingStatus.PENDING
self.total_price: Optional[Money] = None
self.events: List[dict] = []
self._record_event("BookingCreated", {
"room": room.room_number,
"dates": f"{date_range.check_in} to {date_range.check_out}"
})
def confirm(self, pricing_service: 'PricingService', guest: 'Guest'):
"""โ
Confirm the booking"""
if self.status != BookingStatus.PENDING:
raise ValueError("Can only confirm pending bookings! ๐ซ")
self.total_price = pricing_service.calculate_booking_price(
self.room, self.date_range, guest.loyalty_level
)
self.status = BookingStatus.CONFIRMED
self.room.bookings.append(self)
self._record_event("BookingConfirmed", {
"price": self.total_price.amount,
"loyalty_discount": guest.loyalty_level.discount
})
print(f"โ
Booking confirmed! {self.room.room_type.emoji}")
print(f"๐ฐ Total: ${self.total_price.amount} for {self.date_range.nights} nights")
def _record_event(self, event_type: str, data: dict):
self.events.append({
"type": event_type,
"timestamp": datetime.now(),
"data": data
})
class Guest:
"""๐ค Guest entity"""
def __init__(self, name: str, email: str):
self.id = str(uuid.uuid4())
self.name = name
self.email = email
self.loyalty_level = LoyaltyLevel.BRONZE
self.total_nights = 0
def update_loyalty_status(self):
"""๐ Update loyalty based on nights stayed"""
if self.total_nights >= 50:
self.loyalty_level = LoyaltyLevel.GOLD
print(f"๐ Congratulations! You're now {self.loyalty_level.emoji} level!")
elif self.total_nights >= 20:
self.loyalty_level = LoyaltyLevel.SILVER
print(f"๐ Great! You've reached {self.loyalty_level.emoji} level!")
class HotelPricingService:
"""๐ฐ Domain service for pricing"""
def calculate_booking_price(self, room: Room, date_range: DateRange,
loyalty_level: LoyaltyLevel) -> Money:
"""๐งฎ Calculate total price with all factors"""
base_price = room.room_type.base_price
nights = date_range.nights
# ๐ Seasonal pricing (summer is peak)
if date_range.check_in.month in [6, 7, 8]:
base_price *= 1.5 # 50% increase
print("โ๏ธ Peak season pricing applied!")
subtotal = base_price * nights
# ๐ Apply loyalty discount
discount = subtotal * loyalty_level.discount
total = subtotal - discount
if discount > 0:
print(f"{loyalty_level.emoji} Loyalty discount: ${discount:.2f}")
return Money(round(total, 2))
# ๐ฎ Test our hotel system!
hotel_room = Room("101", RoomType.SUITE)
guest = Guest("Alice Johnson", "[email protected]")
guest.total_nights = 25 # Silver member!
guest.update_loyalty_status()
dates = DateRange(
date.today() + timedelta(days=30),
date.today() + timedelta(days=33)
)
booking = Booking(guest.id, hotel_room, dates)
pricing_service = HotelPricingService()
booking.confirm(pricing_service, guest)
print(f"\n๐จ Room amenities: {', '.join(hotel_room.amenities)}")
๐ Key Takeaways
Youโve learned so much! Hereโs what you can now do:
- โ Create rich domain models with confidence ๐ช
- โ Implement DDD building blocks (entities, value objects, aggregates) ๐ก๏ธ
- โ Apply ubiquitous language in real projects ๐ฏ
- โ Design bounded contexts like a pro ๐
- โ Build event-driven systems with Python! ๐
Remember: DDD is about modeling your software to match your business domain. Itโs here to help you write better, more maintainable code! ๐ค
๐ค Next Steps
Congratulations! ๐ Youโve mastered Domain-Driven Design in Python!
Hereโs what to do next:
- ๐ป Practice with the hotel booking exercise above
- ๐๏ธ Apply DDD to your current projectโs most complex domain
- ๐ Move on to our next tutorial: Event Sourcing and CQRS
- ๐ Share your DDD journey with others!
Remember: Every DDD expert was once a beginner. Keep modeling, keep learning, and most importantly, have fun building systems that truly reflect your business! ๐
Happy coding! ๐๐โจ