Skip to content

Commit b5f0262

Browse files
Add Null Object and Singleton patterns, enhance Specification
- Add Null Object behavioral pattern with Customer/NullCustomer example - Add Singleton creational pattern with AppConfig example - Enhance Specification with real-world e-commerce product filtering example, full docstrings, and additional doctests
1 parent 74151cf commit b5f0262

3 files changed

Lines changed: 398 additions & 8 deletions

File tree

patterns/behavioral/null_object.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""
2+
http://code.tutsplus.com/articles/null-object-design-pattern--mobile-19377
3+
4+
*TL;DR
5+
Provides a default object that acts as a surrogate for a missing object,
6+
avoiding the need for null checks throughout the codebase.
7+
8+
*Examples in Python ecosystem:
9+
Python's logging module uses a NullHandler to discard log records when
10+
no other handlers are configured: https://docs.python.org/3/library/logging.handlers.html#logging.NullHandler
11+
"""
12+
13+
# null_object.py
14+
15+
from __future__ import annotations
16+
from typing import Union
17+
18+
19+
class Customer:
20+
"""
21+
A real customer with actual behavior.
22+
"""
23+
24+
def __init__(self, name: str, email: str) -> None:
25+
"""
26+
Initialize a customer with a name and email.
27+
28+
Args:
29+
name (str): The customer's name.
30+
email (str): The customer's email address.
31+
"""
32+
self.name = name
33+
self.email = email
34+
35+
def send_notification(self, message: str) -> None:
36+
"""
37+
Send a notification to the customer.
38+
39+
Args:
40+
message (str): The notification message.
41+
"""
42+
print(f"Sending '{message}' to {self.name} at {self.email}")
43+
44+
def is_null(self) -> bool:
45+
"""
46+
Indicate that this is a real customer, not a null object.
47+
"""
48+
return False
49+
50+
51+
class NullCustomer(Customer):
52+
"""
53+
A null object that mimics a Customer but performs no action.
54+
Used as a safe default when a real customer is not found.
55+
"""
56+
57+
def __init__(self) -> None:
58+
"""
59+
Initialize the null customer with default empty values.
60+
"""
61+
self.name = "N/A"
62+
self.email = "N/A"
63+
64+
def send_notification(self, message: str) -> None:
65+
"""
66+
Override to do nothing instead of sending a notification.
67+
68+
Args:
69+
message (str): The notification message (ignored).
70+
"""
71+
pass
72+
73+
def is_null(self) -> bool:
74+
"""
75+
Indicate that this is a null customer.
76+
"""
77+
return True
78+
79+
80+
class CustomerRepository:
81+
"""
82+
A simple repository that stores and retrieves customers.
83+
Returns a NullCustomer when the requested customer is not found.
84+
"""
85+
86+
def __init__(self) -> None:
87+
self._customers = {
88+
1: Customer("Ahmed", "ahmed@example.com"),
89+
2: Customer("Sara", "sara@example.com"),
90+
}
91+
92+
def get_customer(self, customer_id: int) -> Union[Customer, NullCustomer]:
93+
"""
94+
Retrieve a customer by ID, or return a NullCustomer if not found.
95+
96+
Args:
97+
customer_id (int): The customer's unique identifier.
98+
99+
Returns:
100+
Customer or NullCustomer: The matching customer or a null object.
101+
"""
102+
return self._customers.get(customer_id, NullCustomer())
103+
104+
105+
def main():
106+
"""
107+
>>> repo = CustomerRepository()
108+
109+
# Retrieve an existing customer and send a notification
110+
>>> customer = repo.get_customer(1)
111+
>>> customer.is_null()
112+
False
113+
>>> customer.send_notification("Your order has shipped")
114+
Sending 'Your order has shipped' to Ahmed at ahmed@example.com
115+
116+
# Retrieve a non-existing customer - returns NullCustomer
117+
>>> missing = repo.get_customer(999)
118+
>>> missing.is_null()
119+
True
120+
121+
# Calling methods on NullCustomer is safe and does nothing
122+
>>> missing.send_notification("Your order has shipped")
123+
124+
# No null checks needed - the code stays clean
125+
>>> for customer_id in [1, 2, 999]:
126+
... repo.get_customer(customer_id).send_notification("Hello")
127+
Sending 'Hello' to Ahmed at ahmed@example.com
128+
Sending 'Hello' to Sara at sara@example.com
129+
"""
130+
131+
132+
if __name__ == "__main__":
133+
import doctest
134+
135+
doctest.testmod()
Lines changed: 141 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,196 @@
11
"""
22
@author: Gordeev Andrey <gordeev.and.and@gmail.com>
33
4+
https://en.wikipedia.org/wiki/Specification_pattern
5+
46
*TL;DR
5-
Provides recombination business logic by chaining together using boolean logic.
7+
Encapsulates business rules as standalone objects that can be combined using
8+
boolean logic (AND, OR, NOT). This allows complex selection criteria to be
9+
built dynamically without hardcoding conditions throughout the codebase.
10+
11+
*Examples in Python ecosystem:
12+
Django QuerySet API uses Q objects to compose database queries with logical
13+
operators, applying the same composition principle as the Specification pattern:
14+
https://docs.djangoproject.com/en/stable/topics/db/queries/#complex-lookups-with-q-objects
615
"""
716

817
from abc import abstractmethod
918
from typing import Union
1019

1120

1221
class Specification:
22+
"""
23+
Base interface for all specifications.
24+
25+
A specification encapsulates a single business rule and can be combined
26+
with other specifications using logical operators.
27+
"""
28+
1329
def and_specification(self, candidate):
30+
"""Combine this specification with another using logical AND."""
1431
raise NotImplementedError()
1532

1633
def or_specification(self, candidate):
34+
"""Combine this specification with another using logical OR."""
1735
raise NotImplementedError()
1836

1937
def not_specification(self):
38+
"""Return the logical negation of this specification."""
2039
raise NotImplementedError()
2140

2241
@abstractmethod
2342
def is_satisfied_by(self, candidate):
43+
"""
44+
Check whether the given candidate satisfies this specification.
45+
46+
Args:
47+
candidate: The object to evaluate against the rule.
48+
49+
Returns:
50+
bool: True if the candidate satisfies the rule, otherwise False.
51+
"""
2452
pass
2553

2654

2755
class CompositeSpecification(Specification):
56+
"""
57+
Abstract specification that provides default implementations for
58+
combining specifications using AND, OR, and NOT.
59+
60+
All concrete specifications should inherit from this class to gain
61+
automatic support for logical composition.
62+
"""
63+
2864
@abstractmethod
2965
def is_satisfied_by(self, candidate):
3066
pass
3167

3268
def and_specification(self, candidate: "Specification") -> "AndSpecification":
69+
"""Return a new specification that is satisfied when both rules are satisfied."""
3370
return AndSpecification(self, candidate)
3471

3572
def or_specification(self, candidate: "Specification") -> "OrSpecification":
73+
"""Return a new specification that is satisfied when at least one rule is satisfied."""
3674
return OrSpecification(self, candidate)
3775

3876
def not_specification(self) -> "NotSpecification":
77+
"""Return a new specification that is satisfied when this rule is NOT satisfied."""
3978
return NotSpecification(self)
4079

4180

4281
class AndSpecification(CompositeSpecification):
82+
"""
83+
Composite specification satisfied only when BOTH inner specifications are satisfied.
84+
"""
85+
4386
def __init__(self, one: "Specification", other: "Specification") -> None:
4487
self._one: Specification = one
4588
self._other: Specification = other
4689

47-
def is_satisfied_by(self, candidate: Union["User", str]) -> bool:
90+
def is_satisfied_by(self, candidate) -> bool:
4891
return bool(
4992
self._one.is_satisfied_by(candidate)
5093
and self._other.is_satisfied_by(candidate)
5194
)
5295

5396

5497
class OrSpecification(CompositeSpecification):
98+
"""
99+
Composite specification satisfied when AT LEAST ONE of the inner specifications is satisfied.
100+
"""
101+
55102
def __init__(self, one: "Specification", other: "Specification") -> None:
56103
self._one: Specification = one
57104
self._other: Specification = other
58105

59-
def is_satisfied_by(self, candidate: Union["User", str]):
106+
def is_satisfied_by(self, candidate) -> bool:
60107
return bool(
61108
self._one.is_satisfied_by(candidate)
62109
or self._other.is_satisfied_by(candidate)
63110
)
64111

65112

66113
class NotSpecification(CompositeSpecification):
67-
def __init__(self, wrapped: "Specification"):
114+
"""
115+
Composite specification satisfied when the wrapped specification is NOT satisfied.
116+
"""
117+
118+
def __init__(self, wrapped: "Specification") -> None:
68119
self._wrapped: Specification = wrapped
69120

70-
def is_satisfied_by(self, candidate: Union["User", str]):
121+
def is_satisfied_by(self, candidate) -> bool:
71122
return bool(not self._wrapped.is_satisfied_by(candidate))
72123

73124

125+
# ---------------------------------------------------------------------------
126+
# Original example: User / SuperUser
127+
# ---------------------------------------------------------------------------
128+
129+
74130
class User:
75131
def __init__(self, super_user: bool = False) -> None:
76132
self.super_user = super_user
77133

78134

79135
class UserSpecification(CompositeSpecification):
80-
def is_satisfied_by(self, candidate: Union["User", str]) -> bool:
136+
"""Specification satisfied when the candidate is a User instance."""
137+
138+
def is_satisfied_by(self, candidate) -> bool:
81139
return isinstance(candidate, User)
82140

83141

84142
class SuperUserSpecification(CompositeSpecification):
85-
def is_satisfied_by(self, candidate: "User") -> bool:
143+
"""Specification satisfied when the candidate is a super-user."""
144+
145+
def is_satisfied_by(self, candidate) -> bool:
86146
return getattr(candidate, "super_user", False)
87147

88148

149+
# ---------------------------------------------------------------------------
150+
# Real-world example: filtering products in an e-commerce catalog
151+
# ---------------------------------------------------------------------------
152+
153+
154+
class Product:
155+
"""A simple product in an e-commerce catalog."""
156+
157+
def __init__(self, name: str, price: float, category: str, in_stock: bool) -> None:
158+
self.name = name
159+
self.price = price
160+
self.category = category
161+
self.in_stock = in_stock
162+
163+
def __repr__(self) -> str:
164+
return self.name
165+
166+
167+
class PriceBelowSpecification(CompositeSpecification):
168+
"""Specification satisfied when the product's price is below a given limit."""
169+
170+
def __init__(self, limit: float) -> None:
171+
self._limit = limit
172+
173+
def is_satisfied_by(self, candidate: Product) -> bool:
174+
return candidate.price < self._limit
175+
176+
177+
class InCategorySpecification(CompositeSpecification):
178+
"""Specification satisfied when the product belongs to a given category."""
179+
180+
def __init__(self, category: str) -> None:
181+
self._category = category
182+
183+
def is_satisfied_by(self, candidate: Product) -> bool:
184+
return candidate.category == self._category
185+
186+
187+
class InStockSpecification(CompositeSpecification):
188+
"""Specification satisfied when the product is in stock."""
189+
190+
def is_satisfied_by(self, candidate: Product) -> bool:
191+
return candidate.in_stock
192+
193+
89194
def main():
90195
"""
91196
>>> andrey = User()
@@ -101,10 +206,38 @@ def main():
101206
(True, 'ivan')
102207
>>> root_specification.is_satisfied_by(vasiliy), 'vasiliy'
103208
(False, 'vasiliy')
209+
210+
# Real-world example: filtering products in an e-commerce catalog
211+
>>> products = [
212+
... Product('Python Book', 45.0, 'books', True),
213+
... Product('Laptop', 1200.0, 'electronics', True),
214+
... Product('Headphones', 80.0, 'electronics', False),
215+
... Product('Notebook', 5.0, 'stationery', True),
216+
... ]
217+
218+
# Build composable rules
219+
>>> cheap = PriceBelowSpecification(100)
220+
>>> electronics = InCategorySpecification('electronics')
221+
>>> available = InStockSpecification()
222+
223+
# Show cheap products that are in stock
224+
>>> cheap_and_available = cheap.and_specification(available)
225+
>>> [p for p in products if cheap_and_available.is_satisfied_by(p)]
226+
[Python Book, Notebook]
227+
228+
# Show electronics that are out of stock (using NOT)
229+
>>> out_of_stock_electronics = electronics.and_specification(available.not_specification())
230+
>>> [p for p in products if out_of_stock_electronics.is_satisfied_by(p)]
231+
[Headphones]
232+
233+
# Show cheap items OR electronics
234+
>>> cheap_or_electronics = cheap.or_specification(electronics)
235+
>>> [p for p in products if cheap_or_electronics.is_satisfied_by(p)]
236+
[Python Book, Laptop, Headphones, Notebook]
104237
"""
105238

106239

107240
if __name__ == "__main__":
108241
import doctest
109242

110-
doctest.testmod()
243+
doctest.testmod()

0 commit comments

Comments
 (0)