Files of Repositories and factories
wondelai/
Show the full text354 lines
Repositories and Factories
Repositories and Factories are infrastructure-facing patterns in Domain-Driven Design that separate domain logic from persistence and object creation concerns. The Repository provides the illusion of an in-memory collection of aggregates. The Factory encapsulates complex creation logic. Together, they keep the domain model clean and focused on business rules.
Table of Contents
- The Repository Pattern
- The Factory Pattern
- The Specification Pattern
- Ports and Adapters Relationship
The Repository Pattern
A Repository mediates between the domain and data mapping layers, acting like an in-memory collection of domain objects. Domain code uses the repository to obtain aggregates without knowing how they are stored, queried, or reconstructed.
Why Repositories Exist
Without repositories, domain logic becomes tangled with data access:
// Without repository -- domain logic polluted with SQL
def approve_claim(claim_id):
row = db.execute("SELECT * FROM claims WHERE id = ?", claim_id)
claim = Claim(row['id'], row['status'], row['amount'])
claim.approve()
db.execute("UPDATE claims SET status = ? WHERE id = ?", claim.status, claim.id)
// With repository -- domain logic is clean
def approve_claim(claim_id):
claim = claim_repository.find_by_id(claim_id)
claim.approve()
claim_repository.save(claim)
The second version is readable by a domain expert. The first is not.
Repository Interface Design
The repository interface belongs in the domain layer. It speaks the ubiquitous language:
Good repository methods:
find_by_id(order_id)-- straightforward identity lookupfind_pending_orders()-- uses domain language ("pending")find_by_customer(customer_id)-- domain-meaningful queryfind_overdue_invoices(as_of_date)-- business concept in the method name
Bad repository methods:
get_by_status_code(3)-- magic number; what is status 3?query(sql_string)-- leaks persistence technology into the domainfind_all_with_joins()-- technical concern, not domain languageget_by_column("status", "PENDING")-- generic data access, not domain query
Collection-Oriented vs. Persistence-Oriented Repositories
Eric Evans described two flavors of repository, each modeling a different metaphor:
Collection-Oriented Repository
Models the repository as an in-memory collection. You add objects to it and remove objects from it. Changes to retrieved objects are automatically tracked and persisted (like JPA/Hibernate managed entities).
interface OrderRepository:
add(order) # Like collection.add()
remove(order) # Like collection.remove()
find_by_id(id) # Like collection.find()
# No explicit save() -- changes to retrieved objects are auto-tracked
Best with: ORMs that support change tracking (JPA/Hibernate, Entity Framework).
Advantages: Clean domain model; changes feel natural; no explicit save calls.
Disadvantages: "Magic" change tracking can surprise developers; harder to reason about when persistence happens.
Persistence-Oriented Repository
Models the repository as a storage mechanism. You explicitly save objects and the repository does not track changes automatically.
interface OrderRepository:
save(order) # Explicit persist/update
delete(order_id) # Explicit remove
find_by_id(id) # Retrieve
# Must call save() explicitly after making changes
Best with: Frameworks without change tracking (most non-ORM approaches, event sourcing, document stores).
Advantages: Explicit control over when persistence happens; no surprises; easier to test.
Disadvantages: Must remember to call save(); risk of losing changes if save is forgotten.
Which to choose: If your persistence technology offers change tracking and your team is comfortable with it, use collection-oriented. Otherwise, use persistence-oriented. The persistence-oriented style is more common in modern applications because it is more explicit.
Repository Implementation
The repository interface lives in the domain layer. The implementation lives in the infrastructure layer. This is the Dependency Inversion Principle in action:
domain/
model/
Order.py # Aggregate root
OrderLineItem.py # Entity within aggregate
repository/
OrderRepository.py # Interface (abstract class / protocol)
infrastructure/
persistence/
PostgresOrderRepository.py # Implementation
InMemoryOrderRepository.py # Implementation for tests
The domain layer defines what it needs (the interface). The infrastructure layer provides it (the implementation). The domain never imports from infrastructure.
What a Repository Returns
A repository always returns fully constituted aggregates -- not partial objects, not DTOs, not database rows. The aggregate returned from a repository must be in a valid state with all its invariants satisfied.
Correct: order_repository.find_by_id(id) returns an Order with all its OrderLineItems loaded, ready to have business operations performed on it.
Incorrect: order_repository.find_by_id(id) returns an OrderDTO with some fields populated and others lazily loaded. The caller must check which fields are available.
Repository Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
Generic repository (Repository<T>) |
All aggregates look the same; domain-specific queries do not fit the generic interface | Create specific repository interfaces per aggregate type |
| Repository returns DTOs | DTOs are not domain objects; behavior cannot be called on them | Return full aggregates; use separate read models (CQRS) for queries |
| Repository per entity (not per aggregate) | Bypasses aggregate root; allows direct modification of internal entities | One repository per aggregate root only |
| Repository with business logic | Repository starts containing validation or transformation logic | Keep repositories as pure storage; domain logic belongs in the aggregate |
| Repository depends on domain services | Circular dependency between domain services and repositories | Repositories depend only on the domain model (aggregates, value objects) |
The Factory Pattern
A Factory encapsulates the logic of creating a domain object, ensuring that the object is fully formed and valid from the moment it exists. In DDD, factories are used when object creation is complex enough to warrant its own abstraction.
When to Use a Factory
| Situation | Factory Needed? | Why |
|---|---|---|
| Creating a Value Object with 2-3 fields | No | A constructor suffices: Money(100, "USD") |
| Creating an aggregate with multiple parts and validation rules | Yes | The assembly logic is complex; a constructor would be enormous |
| Creating an object from an external representation (API response, file) | Yes | Translation from external format to domain object is a separate concern |
| Creating an object with conditional logic (different subtypes) | Yes | The decision of which subtype to create should not be in client code |
| Reconstituting an object from persistence | Maybe | If the repository handles it, a separate factory may not be needed |
Factory Patterns in DDD
Factory Method on the Aggregate
The most common pattern: a static or class method on the aggregate itself:
class Order:
@staticmethod
def create_from_cart(cart, customer_id):
# Validates cart is not empty
# Converts cart items to OrderLineItems
# Calculates initial total
# Returns a fully valid Order
order = Order(
id=OrderId.generate(),
customer_id=customer_id,
status=OrderStatus.PENDING,
items=[OrderLineItem.from_cart_item(item) for item in cart.items],
total=cart.calculate_total()
)
order._raise_event(OrderCreated(...))
return order
Advantages: Creation logic lives close to the aggregate. The aggregate controls its own birth.
Factory Method on Another Aggregate
When one aggregate creates another:
class Quote:
def convert_to_order(self):
# Quote knows how to create an Order from itself
order = Order(
id=OrderId.generate(),
customer_id=self.customer_id,
items=[OrderLineItem(item.product_id, item.quantity, item.quoted_price)
for item in self.line_items],
source_quote_id=self.id
)
return order
Standalone Factory
When creation logic does not naturally belong to any existing aggregate:
class LoanApplicationFactory:
def create_from_submission(self, submission, credit_report):
# Complex assembly involving multiple inputs
# Conditional logic based on loan type
# Validation against business rules
applicant = Applicant(submission.name, submission.ssn)
risk_score = RiskScore.calculate(credit_report)
if submission.loan_type == "mortgage":
return MortgageApplication(applicant, risk_score, submission.property)
elif submission.loan_type == "auto":
return AutoLoanApplication(applicant, risk_score, submission.vehicle)
Factory Invariants
The most critical rule of factories: a factory must never produce an invalid object. If the inputs are insufficient or violate business rules, the factory must fail (throw an exception), not produce a partially valid object.
# Good -- factory enforces invariants
class Order:
@staticmethod
def create(customer_id, items):
if not items:
raise EmptyOrderError("Cannot create an order with no items")
if not customer_id:
raise InvalidCustomerError("Order requires a customer")
return Order(OrderId.generate(), customer_id, items)
# Bad -- factory produces potentially invalid objects
class Order:
@staticmethod
def create(customer_id=None, items=None):
return Order(OrderId.generate(), customer_id, items or [])
# caller can now have an order with no customer and no items
Reconstitution vs. Creation
There is an important distinction between creating a new aggregate and reconstituting one from persistence:
| Aspect | Creation | Reconstitution |
|---|---|---|
| When | A new domain object comes into existence | An existing object is loaded from storage |
| Validation | Full business rule validation | No validation needed; data was validated on creation |
| Domain events | May raise creation events (OrderCreated) |
Should NOT raise events; nothing new happened |
| Identity | Generate a new ID | Use the stored ID |
| Invariants | Enforce all invariants | Assume invariants hold (data was valid when stored) |
Reconstitution typically happens inside the repository implementation:
class PostgresOrderRepository:
def find_by_id(self, order_id):
row = self.db.query("SELECT * FROM orders WHERE id = ?", order_id)
items = self.db.query("SELECT * FROM order_items WHERE order_id = ?", order_id)
# Reconstitute -- no validation, no events
return Order._reconstitute(
id=row['id'],
customer_id=row['customer_id'],
status=row['status'],
items=[OrderLineItem._reconstitute(i) for i in items]
)
The Specification Pattern
The Specification pattern encapsulates query criteria as first-class domain objects. Instead of building queries in service code, you express criteria as composable specification objects.
Why Specifications
Without specifications, query logic scatters across the codebase:
# Query logic in a service -- not reusable, not composable
def find_risky_orders(self):
return db.query("SELECT * FROM orders WHERE total > 10000 AND customer_risk > 7")
# Same logic duplicated elsewhere with slight variations
def find_very_risky_orders(self):
return db.query("SELECT * FROM orders WHERE total > 50000 AND customer_risk > 9")
With specifications:
high_value = OrderValueExceeds(10000)
high_risk = CustomerRiskAbove(7)
risky_orders = order_repository.find_matching(high_value.and_(high_risk))
very_risky = OrderValueExceeds(50000).and_(CustomerRiskAbove(9))
very_risky_orders = order_repository.find_matching(very_risky)
Specification Composition
Specifications compose using logical operators:
| Operator | Meaning | Example |
|---|---|---|
and_ |
Both must be true | HighValue.and_(HighRisk) |
or_ |
Either must be true | HighValue.or_(HighRisk) |
not_ |
Must not be true | not_(Cancelled) |
Specifications in the Domain Layer
The specification interface lives in the domain layer. Implementations can be in the domain (for in-memory filtering) or infrastructure (for database queries):
# Domain layer -- specification interface
class Specification:
def is_satisfied_by(self, candidate) -> bool:
pass
class OverdueInvoice(Specification):
def __init__(self, as_of_date):
self.as_of_date = as_of_date
def is_satisfied_by(self, invoice):
return invoice.due_date < self.as_of_date and not invoice.is_paid
Ports and Adapters Relationship
Repositories and Factories fit naturally into the Ports and Adapters (Hexagonal) architecture:
Domain Layer
┌─────────────────────────┐
│ Aggregates │
│ Value Objects │
│ Domain Events │
│ Repository Interfaces ──┼── Port (interface)
│ Factory Interfaces ──┼── Port (interface)
└─────────────────────────┘
│
│ implements
▼
Infrastructure Layer
┌─────────────────────────┐
│ PostgresOrderRepo ──┼── Adapter (implementation)
│ InMemoryOrderRepo ──┼── Adapter (for tests)
│ S3DocumentFactory ──┼── Adapter (implementation)
└─────────────────────────┘
The key principle: The domain defines what it needs (ports). Infrastructure provides it (adapters). Dependencies point inward -- infrastructure depends on domain, never the reverse.
This means:
- The domain layer has zero imports from infrastructure packages
- Repository interfaces use domain types (
Order,OrderId), not infrastructure types (Row,Document) - The application can swap persistence technologies by providing a new adapter without touching domain code
- Tests use in-memory adapters to test domain logic without databases
| 1 | # Repositories and Factories |
| 2 | |
| 3 | Repositories and Factories are infrastructure-facing patterns in Domain-Driven Design that separate domain logic from persistence and object creation concerns. The Repository provides the illusion of an in-memory collection of aggregates. The Factory encapsulates complex creation logic. Together, they keep the domain model clean and focused on business rules. |
| 4 | |
| 5 | |
| 6 | ## Table of Contents |
| 7 | [The Repository Pattern] |
| 8 | [The Factory Pattern] |
| 9 | [The Specification Pattern] |
| 10 | [Ports and Adapters Relationship] |
| 11 | |
| 12 | |
| 13 | |
| 14 | ## The Repository Pattern |
| 15 | |
| 16 | A Repository mediates between the domain and data mapping layers, acting like an in-memory collection of domain objects. Domain code uses the repository to obtain aggregates without knowing how they are stored, queried, or reconstructed. |
| 17 | |
| 18 | ### Why Repositories Exist |
| 19 | |
| 20 | Without repositories, domain logic becomes tangled with data access: |
| 21 | |
| 22 | |
| 23 | // Without repository -- domain logic polluted with SQL |
| 24 | def approve_claim(claim_id): |
| 25 | row = db.execute("SELECT * FROM claims WHERE id = ?", claim_id) |
| 26 | claim = Claim(row['id'], row['status'], row['amount']) |
| 27 | claim.approve() |
| 28 | db.execute("UPDATE claims SET status = ? WHERE id = ?", claim.status, claim.id) |
| 29 | |
| 30 | |
| 31 | |
| 32 | // With repository -- domain logic is clean |
| 33 | def approve_claim(claim_id): |
| 34 | claim = claim_repository.find_by_id(claim_id) |
| 35 | claim.approve() |
| 36 | claim_repository.save(claim) |
| 37 | |
| 38 | |
| 39 | The second version is readable by a domain expert. The first is not. |
| 40 | |
| 41 | ### Repository Interface Design |
| 42 | |
| 43 | The repository interface belongs in the domain layer. It speaks the ubiquitous language: |
| 44 | |
| 45 | **Good repository methods:** |
| 46 | `find_by_id(order_id)` -- straightforward identity lookup |
| 47 | `find_pending_orders()` -- uses domain language ("pending") |
| 48 | `find_by_customer(customer_id)` -- domain-meaningful query |
| 49 | `find_overdue_invoices(as_of_date)` -- business concept in the method name |
| 50 | |
| 51 | **Bad repository methods:** |
| 52 | `get_by_status_code(3)` -- magic number; what is status 3? |
| 53 | `query(sql_string)` -- leaks persistence technology into the domain |
| 54 | `find_all_with_joins()` -- technical concern, not domain language |
| 55 | `get_by_column("status", "PENDING")` -- generic data access, not domain query |
| 56 | |
| 57 | ### Collection-Oriented vs. Persistence-Oriented Repositories |
| 58 | |
| 59 | Eric Evans described two flavors of repository, each modeling a different metaphor: |
| 60 | |
| 61 | #### Collection-Oriented Repository |
| 62 | |
| 63 | Models the repository as an in-memory collection. You add objects to it and remove objects from it. Changes to retrieved objects are automatically tracked and persisted (like JPA/Hibernate managed entities). |
| 64 | |
| 65 | |
| 66 | interface OrderRepository: |
| 67 | add(order) # Like collection.add() |
| 68 | remove(order) # Like collection.remove() |
| 69 | find_by_id(id) # Like collection.find() |
| 70 | # No explicit save() -- changes to retrieved objects are auto-tracked |
| 71 | |
| 72 | |
| 73 | **Best with:** ORMs that support change tracking (JPA/Hibernate, Entity Framework). |
| 74 | |
| 75 | **Advantages:** Clean domain model; changes feel natural; no explicit save calls. |
| 76 | |
| 77 | **Disadvantages:** "Magic" change tracking can surprise developers; harder to reason about when persistence happens. |
| 78 | |
| 79 | #### Persistence-Oriented Repository |
| 80 | |
| 81 | Models the repository as a storage mechanism. You explicitly save objects and the repository does not track changes automatically. |
| 82 | |
| 83 | |
| 84 | interface OrderRepository: |
| 85 | save(order) # Explicit persist/update |
| 86 | delete(order_id) # Explicit remove |
| 87 | find_by_id(id) # Retrieve |
| 88 | # Must call save() explicitly after making changes |
| 89 | |
| 90 | |
| 91 | **Best with:** Frameworks without change tracking (most non-ORM approaches, event sourcing, document stores). |
| 92 | |
| 93 | **Advantages:** Explicit control over when persistence happens; no surprises; easier to test. |
| 94 | |
| 95 | **Disadvantages:** Must remember to call save(); risk of losing changes if save is forgotten. |
| 96 | |
| 97 | **Which to choose:** If your persistence technology offers change tracking and your team is comfortable with it, use collection-oriented. Otherwise, use persistence-oriented. The persistence-oriented style is more common in modern applications because it is more explicit. |
| 98 | |
| 99 | ### Repository Implementation |
| 100 | |
| 101 | The repository interface lives in the domain layer. The implementation lives in the infrastructure layer. This is the Dependency Inversion Principle in action: |
| 102 | |
| 103 | |
| 104 | domain/ |
| 105 | model/ |
| 106 | Order.py # Aggregate root |
| 107 | OrderLineItem.py # Entity within aggregate |
| 108 | repository/ |
| 109 | OrderRepository.py # Interface (abstract class / protocol) |
| 110 | |
| 111 | infrastructure/ |
| 112 | persistence/ |
| 113 | PostgresOrderRepository.py # Implementation |
| 114 | InMemoryOrderRepository.py # Implementation for tests |
| 115 | |
| 116 | |
| 117 | The domain layer defines what it needs (the interface). The infrastructure layer provides it (the implementation). The domain never imports from infrastructure. |
| 118 | |
| 119 | ### What a Repository Returns |
| 120 | |
| 121 | A repository always returns fully constituted aggregates -- not partial objects, not DTOs, not database rows. The aggregate returned from a repository must be in a valid state with all its invariants satisfied. |
| 122 | |
| 123 | **Correct:** `order_repository.find_by_id(id)` returns an `Order` with all its `OrderLineItems` loaded, ready to have business operations performed on it. |
| 124 | |
| 125 | **Incorrect:** `order_repository.find_by_id(id)` returns an `OrderDTO` with some fields populated and others lazily loaded. The caller must check which fields are available. |
| 126 | |
| 127 | ### Repository Anti-Patterns |
| 128 | |
| 129 | | Anti-Pattern | Problem | Fix | |
| 130 | |-------------|---------|-----| |
| 131 | | Generic repository (`Repository<T>`) | All aggregates look the same; domain-specific queries do not fit the generic interface | Create specific repository interfaces per aggregate type | |
| 132 | | Repository returns DTOs | DTOs are not domain objects; behavior cannot be called on them | Return full aggregates; use separate read models (CQRS) for queries | |
| 133 | | Repository per entity (not per aggregate) | Bypasses aggregate root; allows direct modification of internal entities | One repository per aggregate root only | |
| 134 | | Repository with business logic | Repository starts containing validation or transformation logic | Keep repositories as pure storage; domain logic belongs in the aggregate | |
| 135 | | Repository depends on domain services | Circular dependency between domain services and repositories | Repositories depend only on the domain model (aggregates, value objects) | |
| 136 | |
| 137 | ## The Factory Pattern |
| 138 | |
| 139 | A Factory encapsulates the logic of creating a domain object, ensuring that the object is fully formed and valid from the moment it exists. In DDD, factories are used when object creation is complex enough to warrant its own abstraction. |
| 140 | |
| 141 | ### When to Use a Factory |
| 142 | |
| 143 | | Situation | Factory Needed? | Why | |
| 144 | |-----------|----------------|-----| |
| 145 | | Creating a Value Object with 2-3 fields | No | A constructor suffices: `Money(100, "USD")` | |
| 146 | | Creating an aggregate with multiple parts and validation rules | Yes | The assembly logic is complex; a constructor would be enormous | |
| 147 | | Creating an object from an external representation (API response, file) | Yes | Translation from external format to domain object is a separate concern | |
| 148 | | Creating an object with conditional logic (different subtypes) | Yes | The decision of which subtype to create should not be in client code | |
| 149 | | Reconstituting an object from persistence | Maybe | If the repository handles it, a separate factory may not be needed | |
| 150 | |
| 151 | ### Factory Patterns in DDD |
| 152 | |
| 153 | #### Factory Method on the Aggregate |
| 154 | |
| 155 | The most common pattern: a static or class method on the aggregate itself: |
| 156 | |
| 157 | |
| 158 | class Order: |
| 159 | @staticmethod |
| 160 | def create_from_cart(cart, customer_id): |
| 161 | # Validates cart is not empty |
| 162 | # Converts cart items to OrderLineItems |
| 163 | # Calculates initial total |
| 164 | # Returns a fully valid Order |
| 165 | order = Order( |
| 166 | id=OrderId.generate(), |
| 167 | customer_id=customer_id, |
| 168 | status=OrderStatus.PENDING, |
| 169 | items=[OrderLineItem.from_cart_item(item) for item in cart.items], |
| 170 | total=cart.calculate_total() |
| 171 | ) |
| 172 | order._raise_event(OrderCreated(...)) |
| 173 | return order |
| 174 | |
| 175 | |
| 176 | **Advantages:** Creation logic lives close to the aggregate. The aggregate controls its own birth. |
| 177 | |
| 178 | #### Factory Method on Another Aggregate |
| 179 | |
| 180 | When one aggregate creates another: |
| 181 | |
| 182 | |
| 183 | class Quote: |
| 184 | def convert_to_order(self): |
| 185 | # Quote knows how to create an Order from itself |
| 186 | order = Order( |
| 187 | id=OrderId.generate(), |
| 188 | customer_id=self.customer_id, |
| 189 | items=[OrderLineItem(item.product_id, item.quantity, item.quoted_price) |
| 190 | for item in self.line_items], |
| 191 | source_quote_id=self.id |
| 192 | ) |
| 193 | return order |
| 194 | |
| 195 | |
| 196 | #### Standalone Factory |
| 197 | |
| 198 | When creation logic does not naturally belong to any existing aggregate: |
| 199 | |
| 200 | |
| 201 | class LoanApplicationFactory: |
| 202 | def create_from_submission(self, submission, credit_report): |
| 203 | # Complex assembly involving multiple inputs |
| 204 | # Conditional logic based on loan type |
| 205 | # Validation against business rules |
| 206 | applicant = Applicant(submission.name, submission.ssn) |
| 207 | risk_score = RiskScore.calculate(credit_report) |
| 208 | |
| 209 | if submission.loan_type == "mortgage": |
| 210 | return MortgageApplication(applicant, risk_score, submission.property) |
| 211 | elif submission.loan_type == "auto": |
| 212 | return AutoLoanApplication(applicant, risk_score, submission.vehicle) |
| 213 | |
| 214 | |
| 215 | ### Factory Invariants |
| 216 | |
| 217 | The most critical rule of factories: **a factory must never produce an invalid object.** If the inputs are insufficient or violate business rules, the factory must fail (throw an exception), not produce a partially valid object. |
| 218 | |
| 219 | |
| 220 | # Good -- factory enforces invariants |
| 221 | class Order: |
| 222 | @staticmethod |
| 223 | def create(customer_id, items): |
| 224 | if not items: |
| 225 | raise EmptyOrderError("Cannot create an order with no items") |
| 226 | if not customer_id: |
| 227 | raise InvalidCustomerError("Order requires a customer") |
| 228 | return Order(OrderId.generate(), customer_id, items) |
| 229 | |
| 230 | # Bad -- factory produces potentially invalid objects |
| 231 | class Order: |
| 232 | @staticmethod |
| 233 | def create(customer_id=None, items=None): |
| 234 | return Order(OrderId.generate(), customer_id, items or []) |
| 235 | # caller can now have an order with no customer and no items |
| 236 | |
| 237 | |
| 238 | ### Reconstitution vs. Creation |
| 239 | |
| 240 | There is an important distinction between creating a new aggregate and reconstituting one from persistence: |
| 241 | |
| 242 | | Aspect | Creation | Reconstitution | |
| 243 | |--------|---------|----------------| |
| 244 | | When | A new domain object comes into existence | An existing object is loaded from storage | |
| 245 | | Validation | Full business rule validation | No validation needed; data was validated on creation | |
| 246 | | Domain events | May raise creation events (`OrderCreated`) | Should NOT raise events; nothing new happened | |
| 247 | | Identity | Generate a new ID | Use the stored ID | |
| 248 | | Invariants | Enforce all invariants | Assume invariants hold (data was valid when stored) | |
| 249 | |
| 250 | Reconstitution typically happens inside the repository implementation: |
| 251 | |
| 252 | |
| 253 | class PostgresOrderRepository: |
| 254 | def find_by_id(self, order_id): |
| 255 | row = self.db.query("SELECT * FROM orders WHERE id = ?", order_id) |
| 256 | items = self.db.query("SELECT * FROM order_items WHERE order_id = ?", order_id) |
| 257 | # Reconstitute -- no validation, no events |
| 258 | return Order._reconstitute( |
| 259 | id=row['id'], |
| 260 | customer_id=row['customer_id'], |
| 261 | status=row['status'], |
| 262 | items=[OrderLineItem._reconstitute(i) for i in items] |
| 263 | ) |
| 264 | |
| 265 | |
| 266 | ## The Specification Pattern |
| 267 | |
| 268 | The Specification pattern encapsulates query criteria as first-class domain objects. Instead of building queries in service code, you express criteria as composable specification objects. |
| 269 | |
| 270 | ### Why Specifications |
| 271 | |
| 272 | Without specifications, query logic scatters across the codebase: |
| 273 | |
| 274 | |
| 275 | # Query logic in a service -- not reusable, not composable |
| 276 | def find_risky_orders(self): |
| 277 | return db.query("SELECT * FROM orders WHERE total > 10000 AND customer_risk > 7") |
| 278 | |
| 279 | # Same logic duplicated elsewhere with slight variations |
| 280 | def find_very_risky_orders(self): |
| 281 | return db.query("SELECT * FROM orders WHERE total > 50000 AND customer_risk > 9") |
| 282 | |
| 283 | |
| 284 | With specifications: |
| 285 | |
| 286 | |
| 287 | high_value = OrderValueExceeds(10000) |
| 288 | high_risk = CustomerRiskAbove(7) |
| 289 | risky_orders = order_repository.find_matching(high_value.and_(high_risk)) |
| 290 | |
| 291 | very_risky = OrderValueExceeds(50000).and_(CustomerRiskAbove(9)) |
| 292 | very_risky_orders = order_repository.find_matching(very_risky) |
| 293 | |
| 294 | |
| 295 | ### Specification Composition |
| 296 | |
| 297 | Specifications compose using logical operators: |
| 298 | |
| 299 | | Operator | Meaning | Example | |
| 300 | |----------|---------|---------| |
| 301 | | `and_` | Both must be true | `HighValue.and_(HighRisk)` | |
| 302 | | `or_` | Either must be true | `HighValue.or_(HighRisk)` | |
| 303 | | `not_` | Must not be true | `not_(Cancelled)` | |
| 304 | |
| 305 | ### Specifications in the Domain Layer |
| 306 | |
| 307 | The specification interface lives in the domain layer. Implementations can be in the domain (for in-memory filtering) or infrastructure (for database queries): |
| 308 | |
| 309 | |
| 310 | # Domain layer -- specification interface |
| 311 | class Specification: |
| 312 | def is_satisfied_by(self, candidate) -> bool: |
| 313 | pass |
| 314 | |
| 315 | class OverdueInvoice(Specification): |
| 316 | def __init__(self, as_of_date): |
| 317 | self.as_of_date = as_of_date |
| 318 | |
| 319 | def is_satisfied_by(self, invoice): |
| 320 | return invoice.due_date < self.as_of_date and not invoice.is_paid |
| 321 | |
| 322 | |
| 323 | ## Ports and Adapters Relationship |
| 324 | |
| 325 | Repositories and Factories fit naturally into the Ports and Adapters (Hexagonal) architecture: |
| 326 | |
| 327 | |
| 328 | Domain Layer |
| 329 | ┌─────────────────────────┐ |
| 330 | │ Aggregates │ |
| 331 | │ Value Objects │ |
| 332 | │ Domain Events │ |
| 333 | │ Repository Interfaces ──┼── Port (interface) |
| 334 | │ Factory Interfaces ──┼── Port (interface) |
| 335 | └─────────────────────────┘ |
| 336 | │ |
| 337 | │ implements |
| 338 | ▼ |
| 339 | Infrastructure Layer |
| 340 | ┌─────────────────────────┐ |
| 341 | │ PostgresOrderRepo ──┼── Adapter (implementation) |
| 342 | │ InMemoryOrderRepo ──┼── Adapter (for tests) |
| 343 | │ S3DocumentFactory ──┼── Adapter (implementation) |
| 344 | └─────────────────────────┘ |
| 345 | |
| 346 | |
| 347 | **The key principle:** The domain defines what it needs (ports). Infrastructure provides it (adapters). Dependencies point inward -- infrastructure depends on domain, never the reverse. |
| 348 | |
| 349 | This means: |
| 350 | The domain layer has zero imports from infrastructure packages |
| 351 | Repository interfaces use domain types (`Order`, `OrderId`), not infrastructure types (`Row`, `Document`) |
| 352 | The application can swap persistence technologies by providing a new adapter without touching domain code |
| 353 | Tests use in-memory adapters to test domain logic without databases |
| 354 |
Discussion
Alternatives
Browse more free Claude skills or everything in Development.