Architecture Patterns with Python

나다·2023년 9월 2일
0

Part 1. Building an Architecture to Support Domain Modeling

Chapter 1. Domain Modeling

What is a Domain Model

  • Business logic layer : central layer of 3 layered architecture
  • domain : fancy way of saying the problem you're trying to solve
  • model : map of a process of phenomenon that captures a useful property
  • domain model : mental map that business owners have of their businesses

Exploring the Domain Lanaguage

  • Understanding the domain model takes time and patience, and Post-it notes
  • We have initial conversation with our business experts

Unit Testing Domain Models

def test_allocating_to_a_batch_reduces_the_available_quantity():
	batch = Batch("batch-001", "SMALL-TABLE", qty=20, eta=date.today()) 
	line = OrderLine('order-ref', "SMALL-TABLE", 2)
    
	batch.allocate(line)
    
	assert batch.available_quantity == 18
  • models.py (domain model)
# immutable dataclass with no behavior
@dataclass(frozen=True) 
class OrderLine:
	orderid: str 
	sku: str 
	qty: int


class Batch:
	def __init__(self, ref: str, sku: str, qty: int, eta: Optional[date]):
		self.reference = ref 
		self.sku = sku
		self.eta = eta 
		self.available_quantity = qty

	def allocate(self, line: OrderLine): 
		self.available_quantity -= line.qty

+) 느낀점 : models.py 안에 다 넣어놓네..? Flask 를 쓰긴 했지만, 이게 맞는 건가? model 을 따로 만들어야 하나..?

More Types For More Type hints

from dataclasses import dataclass 
from typing import NewType

Quantity = NewType("Quantity", int) 
Sku = NewType("Sku", str)
Reference = NewType("Reference", str) 

...

class Batch:
	def __init__(self, ref: Reference, sku: Sku, qty: Quantity):
	self.sku = sku
	self.reference = ref 
	self._purchased_quantity = qty
  • wrapping primitive types by using typing.NewType

Dataclasses Are Great for Value Objects

Value Object

  • business concept that has data but no identity
  • any domain object that is uniquely identified by the data it holds
@dataclass(frozen=True) 
class OrderLine:
	orderid: OrderReference 
	sku: ProductReference 
	qty: Quantity

Value Objects and Entities

  • entity : describe a domain object that has long-lived identity
def test_name_equality():
	assert Name("Harry", "Percival") != Name("Barry", "Percival")
  • People do change their names and their marital status etc, but we continue to recognize them as the same individual
class Person:
	def __init__(self, name: Name):
		self.name = name
  • Entity unlike values have identity equality
  • We can change their values and they are still recognizably the same thing

+) explicit in code by implementing equality operators on entites

class Batch: 
	...

	def __eq__(self, other): # magic method
		if not isinstance(other, Batch):
			return False
		return other.reference == self.reference

	def __hash__(self): # compare by this value
		return hash(self.reference)

Not Everything Has to Be and Object: A Domain Service Function

def test_prefers_current_stock_batches_to_shipments():
	in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None) 
	line = OrderLine("oref", "RETRO-CLOCK", 10)

	allocate(line, [in_stock_batch]) 

	assert in_stock_batch.available_quantity == 90
  • models.py
def allocate(line: OrderLine, batches: List[Batch]) -> str: 
	batch = next(
		b for b in sorted(batches) if b.can_allocate(line) 
	)
	batch.allocate(line) 
	
	return batch.reference

Python's Magic Methods Let Us Use Our Models with Idiomatic Python

class Batch: 
	...
	def __gt__(self, other): 
		if self.eta is None:
			return False
		if other.eta is None:
			return True
		return self.eta > other.eta

Exception Can Express Domain concepts Too

  • Rasing a domain exception
class OutOfStock(Exception): 
	pass

def allocate(line: OrderLine, batches: List[Batch]) -> str: 
	try:
		batch = next( 
			...
	except StopIteration:
		raise OutOfStock(f'Out of stock for sku {line.sku}')

Chapter 2. Repository Pattern

  • Repository Pattern : simplifying abstraction over data storage, allowing us to de ouple our model layer from the data layer

Applying the DIP to Data Access

The "Normal" ORM Way: Model Depends on ORM

  • object-relational mappers (ORMs) : exist to bridge the conceptual gap between the world of objects and domain modeling and the world of databases and relational algebra
  • ORM gives us is persistence ignorance : fancy model doesn't need to know anything about how data is loaded or persisted

Inverting the Dependency: ORM Depends on Model

from sqlalchemy.orm import mapper, relationship 
import model

metadata = MetaData()

order_lines = Table(
	'order_lines', metadata,
	Column('id', Integer, primary_key=True, autoincrement=True), 
	Column('sku', String(255)),
	Column('qty', Integer, nullable=False),
	Column('orderid', String(255)),
)

...

def start_mappers():
	# does its magic to bind our domain model classes to the barious tables we've defined
	lines_mapper = mapper(model.OrderLine, order_lines)
def test_orderline_mapper_can_load_lines(session): 
	session.execute(
		'INSERT INTO order_lines (orderid, sku, qty) VALUES ("order1", "RED-CHAIR", 12)'
	)
	expected = [
		model.OrderLine("order1", "RED-CHAIR", 12)
	]
	assert session.query(model.OrderLine).all() == expected
  • You probably wouldn't keep these tests around
  • you've taken the step of inverting the dependency of ORM and domain model
  • easier to write tests against and will provide a simple interface for faking out later in tests

Introducing the Repository Pattern

  • Repository Pattern : abstraction over persistent storage
  • it hides the boring details of data access by pretending that all of our data is in memory
  • get your data from somewhere
import all_my_data

def create_a_batch():
	batch = Batch(...) 
    all_my_data.batches.add(batch)
  • Eventhough our objects are in memory, we need to put them somewhere so we can find them again
  • Our memory data would let us add new objects, just like a list or a set
  • the objects are in memory, we never need to call a .save() method
  • we just fetch the objects we care about and modify it in memory

The Repository in the Abstract

class AbstractRepository(abc.ABC):

	@abc.abstractmethod
	def add(self, batch: model.Batch): 
		raise NotImplementedError

	@abc.abstractmethod
	def get(self, reference) -> model.Batch: 
		raise NotImplementedError

What is the Trade-Off?

def test_repository_can_save_a_batch(session):
	batch = model.Batch("batch1", "RUSTY-SOAPDISH", 100, eta=None)

	repo = repository.SqlAlchemyRepository(session) 
	repo.add(batch)
	session.commit()

	rows = list(session.execute(
		'SELECT reference, sku, _purchased_quantity, eta FROM "batches"'))
		
	assert rows == [("batch1", "RUSTY-SOAPDISH", 100, None)]
class SqlAlchemyRepository(AbstractRepository): d
	ef __init__(self, session):
		self.session = session 

	def add(self, batch):
		self.session.add(batch)
	
	def get(self, reference): 
		return self.session.query(model.Batch).filter_by(reference=reference).one()

	def list(self):
		return self.session.query(model.Batch).all()
@flask.route.gubbins
def allocate_endpoint():
	batches = SqlAlchemyRepository.list() 

	lines = [
		OrderLine(l['orderid'], l['sku'], l['qty']) for l in request.params...
	]	

	allocate(lines, batches)
	session.commit() 
	
	return 201

+) Chapter 2. Repository Pattern Excercise

Building a Fake Repository for Tests Is Now Trivial!

class FakeRepository(AbstractRepository): 
	def __init__(self, batches):
		self._batches = set(batches) 

	def add(self, batch):
		self._batches.add(batch) 

	def get(self, reference):
		return next(b for b in self._batches if b.reference == reference)
	
	def list(self):
		return list(self._batches)

+) 진짜..? 흠..


Chapter 3. A Brief Interlude: On Coupling and Abstractions

  • The Repository pattern is an abstraction over pemanent storage
  • What makes a good abstraction?
  • What do wwe want from abstractions?
  • that we can use simple abstractions to hide messy details
  • When we're unable to change component A for fear of breaking component B, we say that the components have become coupled

  • reduced the degree of coupling by inserting a new, simpler abstraction

Abstracting State Aids Testability

  • 뭐 Abstracting 을 쓰면 당연히 Test 가 간단해지겠죠? 넘어가시죠

Mock (London School TDD) vs Fakes (Classic Style)

Mocks Aren't Stubs

  • It also probably doesn't help that the MagicMock objects provided by unittest.ock aren't strictly speaking, mocks; they're spies
  • But they're also often used as stubs or dummies
  • There, we promise we're done with the test double terminology nitpicks now

+) 이렇게까지 말할 필요가 있어..? ㅠ 흑..


Chapter 4. Our First Use Case: Flask API and Service Layer

여기는 그냥 여러 Example 들이 있는 데 왜 난 보고 싶지 않지

Why is Everything Called a Service

  • domain service & service layer

application service (service layer)

  • get some data from the database
  • update the domain model
  • persis any changes

domain service

  • pice of logic that belongs in the domain model
  • but doesn't sit naturally inside a stateful entity or value object

Chapter 5. TDD in High Gear and Low Gear

  • service layer to capture some of the additional orchestration responsibilities we need from a working application
  • service layer helps us clearly define our use cases and the workflow for each
  • what we need to get from our repositories
  • what pre-checks and current state validation we should do,
  • and what we save at the end

Should Domain Layer Tests Move to the Service Layer?

  • Tests are supposed to help us change our system fearlessly
  • often we see teams writing too many tests against their domain model
  • this causes problems when they come to change their codebase and find that they need to update tnes or even hundereds of unit tests

On Deciding What Kind of Tests to Write

  • we can rewrite our entire application and so long as we don't change the URLs or request formats, our HTTP tests will continue to pass
  • This gives us confidence that large-scale changes (ex) DB Schema)
  • the tests we wrote in Chapter 1 helped us to flesh out our understnading of the objects we need
  • the tests guided us to a design that makes sense and reads in the domain language
  • tests are written in the domain language, they act as living documentation for our model
  • we often sketch new behaviors by writing tests at this level to see how the code might look

we will need to replace or delete these tests, because they are tightly coupled to a particular implementation

High and Low Gear

  • we prefer to write tests against services because of the lower coupling and higher coverage

Chapter 6. Unit of Work Pattern

  • Unit of Work (UoW) Pattern : our abstraction over the idea of atomic operations
  • allow us to finally and fully decouple our service layer from the data layer
def allocate(
	orderid: str, sku: str, qty: int, 
	uow: unit_of_work.AbstractUnitOfWork
) -> str:
	line = OrderLine(orderid, sku, qty) 

	with uow:
		batches = uow.batches.list()

		...
		
		batchref = model.allocate(line, batches) 
		uow.commit()
  • UoW as a context manager
  • uow.batches : batches repo, so UoW provides us access to our permanent storage

Unit of Work and Its Context Manager

  • unit_of_work.py
class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
	def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):
		self.session_factory = session_factory
        ...
class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork):
	def __init__(self):
		self.batches = FakeRepository([])
		...
  • FakeUnitOfWork and FakeRepository are tightly coupled, just like the real UnitOfWork, Repository classes
def test_add_batch():
	uow = FakeUnitOfWork()
	services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow) 
	assert uow.batches.get("b1") is not None
	assert uow.committed

Don't mock what you don't own

  • forces us to build these simple abstractions over messy subsystems

Chapter 7. Aggregates and Consistency Boundaries

What is an Aggregate

  • if we can't lock the whole database every time we want to allocate an order line, what should we do instead?
  • we want to protect the invariants of our system but allow for the greatest degree of concurrency

Aggregate Pattern

  • aggregate : a domain object that contains other domain objects and lets us treat the whole collection as a single unit
  • the only way to modify the objects inside the aggregate is to load the whold thing, and to call methods on the aggregate itself
  • good idea to nominate some entities to be the single entrypoint for modifying their related objects
  • our aggregate has a root entity that encapsulates access to items

Choosing an Aggregate

  • somewhat arbitrary, but it's important
  • aggregate will be the boundary where we make sure every operation ends in a consistent state

One Aggregate = One Repository

  • we need to apply the rule that they are the only entities that are publicly accessible to the outside world
  • only repositories we are allowed should be repositories that return aggregates

Optimistic Concurrency with Version Numbers

1. Use Version

2. Select for Update

def get(self, sku):
	return self.session.query(model.Product) \
					   .filter_by(sku=sku) \ 
					   .with_for_update() \ 
					   .first()
profile
매일 한걸음씩만 더 성장하기 위해 노력하고 있습니다.

0개의 댓글