0% found this document useful (0 votes)
69 views34 pages

Common Design Failures and Solutions

The document discusses common design failures in software development, such as lack of encapsulation, weak abstractions, uncontrolled dependencies, and violations of the Dependency Inversion Principle (DIP). It emphasizes the importance of proper encapsulation, layering, and maintaining a clear domain model to create a stable and change-friendly architecture. Additionally, it introduces key patterns like the Repository Pattern and Domain Services to decouple the domain model from infrastructure, ensuring that business logic remains pure and testable.

Uploaded by

r7503047633
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
69 views34 pages

Common Design Failures and Solutions

The document discusses common design failures in software development, such as lack of encapsulation, weak abstractions, uncontrolled dependencies, and violations of the Dependency Inversion Principle (DIP). It emphasizes the importance of proper encapsulation, layering, and maintaining a clear domain model to create a stable and change-friendly architecture. Additionally, it introduces key patterns like the Repository Pattern and Domain Services to decouple the domain model from infrastructure, ensuring that business logic remains pure and testable.

Uploaded by

r7503047633
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd

1. Why Do Our Designs Go Wrong?

Designs go wrong because we mix concerns, fail to encapsulate behavior, ignore


abstractions, allow dependencies to grow uncontrolled, violate DIP, and scatter business
logic everywhere.
Designs succeed when we encapsulate behavior, choose clean abstractions, enforce
layers, apply DIP, and centralize business logic in a domain model.

Root Causes of Design Failure


1. Lack of Encapsulation
Code ends up manipulating too many details directly:
networking logic mixed with parsing
business rules mixed with infrastructure
UI code tangled with database queries
This happens when we don't isolate behaviors into well-defined units (functions, classes,
modules).

2. Weak or Missing Abstractions


Instead of naming concepts (e.g., SearchService, OrderRepository, EmailGateway),
developers write procedural code everywhere.

The urllib vs requests shows this:


• Low abstraction → verbose, harder to understand
• Proper abstraction → expressive, testable, clean
• High-level abstraction → business-intent becomes obvious
When we fail to create the right abstraction, the code becomes noisy and hard to change.

3. Uncontrolled Dependencies
Dependencies form a web.
Any single change breaks many parts.

This happens when:


• UI calls business logic and DB directly
• business logic directly imports infrastructure
• modules know too much about each other
Uncontrolled dependencies produce tight coupling → change is risky, slow, expensive.

4. Business Logic Leaks Across Layers


One common reason our designs fail:
• business rules spread across UI, services, data layer, and random helper modules.

When domain logic becomes scattered:


• it cannot be reasoned about
• it cannot be tested easily
• changes become unpredictable
This is where most architectures collapse.

5. Violating the Dependency Inversion Principle (DIP)


When high-level modules depend directly on low-level details:
• the business logic depends on HTTP, SQL, APIs, file systems
• infrastructure choices block business evolution
• changing technical systems requires touching business code
Result: brittle, tightly coupled architecture.

Solutions:
1. Use Encapsulation + Abstraction Properly
What to do:
• Identify behaviors in your code.
• Put each behavior behind a meaningful abstraction.

Benefits:
• Code matches business intent
• Easier to test
• Simpler to replace implementations
• Less cognitive load

2. Introduce Layering (Controlled Dependencies)


A layered architecture prevents chaos:
UI → Business Logic → Persistence Layer

Rules:
• UI only talks to business layer
• business layer only talks to storage via abstractions
• storage layer does not know anything about UI

Why this helps:


• isolates concerns
• reduces dependency complexity
• makes the graph predictable instead of a hairball

3. Apply the Dependency Inversion Principle (DIP)


DIP states:
• High-level modules (business rules) should depend on abstractions, not
infrastructure.
• Infrastructure (details) implements abstractions, not vice versa.
Result:
• business logic stays pure
• infrastructure can be swapped (SQL → Redis → API)
• changes in low-level modules never break high-level ones

4. Maintain a Clear Domain Model


The text emphasizes:
Designs fail because business logic becomes scattered across layers.

Solution:
Put all business rules inside a Domain Model layer.
Protect it from UI and database code.
Use abstractions and DIP so domain remains independent.
This makes architecture stable and change-friendly.

Part I. Building an Architecture to Support Domain Modeling


1. Why Most Architectures Go Wrong
Developers usually start with a database schema first, not the domain model.
This makes the data model dictate behavior, instead of business behavior shaping the
system.
Result: business logic becomes scattered, leading to a “ball of mud.”

2. Domain Modeling Comes First


Behavior > Data
Customers care about what the system does, not how you store it.
Build a rich domain model that expresses business rules and invariants.
Use TDD to incrementally build domain behavior.

3. Persistence-Ignorant Domain
Domain code should not know about databases, ORMs, frameworks, HTTP, or
configuration.
Keep domain decoupled so it remains easy to change, test, and reason about.
Technical concerns are added later through abstractions.

4. Four Key Patterns Introduced in Part I


a) Repository Pattern
Abstracts access to persistent storage.
Allows domain logic to work with “collections” of objects, not SQL/ORM code.
Example: OrderRepository, ProductRepository.

b) Service Layer Pattern


Defines use cases or application workflows.
Coordinates domain objects → keeps controllers/UI thin.
Marks clear boundaries: start → execute domain logic → return result.

c) Unit of Work Pattern


Manages atomic operations.
Tracks changes and commits them as a single transaction.
Prevents partial writes and ensures consistency.

d) Aggregate Pattern
Groups domain objects into consistency boundaries.
Ensures business rules and invariants remain correct.
Only aggregate roots can be loaded/modified directly.

5. Goal of Part I
Build a clean, decoupled domain layer that:
is easy to test
free from infrastructure concerns
can be refactored aggressively
supports stable APIs

Domain Modeling
2. What Is a Domain Model?
Domain = the business problem you are solving (e.g., allocating stock).
Model = a simplified, useful map of real-world business rules.
Domain model = representation of business concepts and rules in code.

3. Domain Language (Ubiquitous Language)


Business stakeholders naturally create precise terminology.
Developers must listen to this language and encode it into code.
Terms: SKU, Order, Order Line, Batch, ETA, Allocation, etc.

4. Example Domain: Stock Allocation


Key concepts:
• OrderLine → SKU + quantity
• Batch → reference, SKU, purchased quantity, optional ETA

Allocate order lines to batches based on:


• matching SKU
• enough available quantity
• warehouse stock preferred over shipments
• earliest ETA chosen
5. TDD for Domain Modeling
Write behavior-based tests using real domain language.

Example behaviors:
• allocating reduces available quantity
• cannot allocate if insufficient stock
• cannot allocate mismatched SKU
• allocation is idempotent
• deallocation should only affect previously allocated lines

6. Evolving the Batch Model


Initial version:
simple decrement of quantity.

Improved version:
track allocated lines (as a set)

computed properties:
allocated_quantity
available_quantity = purchased_quantity – allocated_quantity
can_allocate(), allocate(), deallocate() implemented with domain logic.

7. Value Objects
Definition: Identified only by their data, not identity.
Immutable.

Examples:
OrderLine
Name
Money

Useful features:
• value equality
• safe to use in sets, dict keys

8. Entities
Definition: Have long-lived identity, independent of their changing attributes.
Example: Batch (identified by .reference).
Entity equality is based on identity, not attribute values.
Usually mutable (attributes may change).

9. Equality and Hashing


Value Objects:
• Implemented automatically by @dataclass(frozen=True).
• Hash based on all fields.

Entities:
• Hash and equality should be based on the identity attribute (e.g., reference).
• Must define __eq__ and __hash__ consistently.

10. Domain Service (Mentioned as Pattern)


• Used when behavior doesn’t naturally belong to an Entity or Value Object.
• Stateless operations coordinating domain concepts.

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


1. Domain Service Concept
• Used when behavior does not naturally belong to an Entity or Value Object.
• Represents a business operation, not an object.
• In Python, a domain service can simply be a function.

Example domain service:


allocate(order_line, batches) — allocates an order line to the most suitable batch.

2. Behavior of the Allocation Function


What it must do
• Choose the best batch based on business rules:
• Prefer in-stock batches (ETA = None) over shipment batches.
• Otherwise choose the earliest ETA.
• Allocate the line by calling [Link](line).
• Return the reference of the chosen batch.

Why it is a function
• Allocation is a verb-like operation across multiple batches.
• It doesn’t belong to one entity.
• Python allows a clean functional style for such logic.

3. Using Sorted Batches (Magic Methods)


To make sorted(batches) work meaningfully:
Implement comparison logic (__gt__) in Batch.

Domain semantics encoded:


• Batch with ETA = None (in stock) is “earlier” than any dated batch.
• Otherwise compare ETAs.
Magic methods allow domain behavior to feel like natural Python.

4. Domain Exceptions
Exceptions express business rules, not technical failures.
Example: cannot allocate due to insufficient stock → OutOfStock exception.

Exception rules:
• Raise OutOfStock when no suitable batch found.
• Name exceptions using the ubiquitous language of the domain.

5. Test-Driven Domain Service


Tests should cover:
• Preferring in-stock batches over shipments.
• Choosing earliest ETA.
• Returning the batch reference.
• Raising OutOfStock when needed.

6. Design Principles Reinforced


Not everything needs to be an object → domain services can be functions.

Apply good OO heuristics:


• Prefer composition over inheritance.
• Correctly distinguish entities vs value objects.
• Use SOLID, especially SRP and DIP.

7. Distinguishing Entities & Value Objects


• Value Objects: Immutable, equality by value (e.g., OrderLine).
• Entities: Identity matters, attributes may change (e.g., Batch).
• Must define what uniquely identifies an entity.

8. Domain Model State at End of Chapter


• Entities: Batch
• Value objects: OrderLine
• Domain service: allocate()
• Domain exception: OutOfStock
Batches sortable due to magic methods

Repository Pattern
1. Purpose of the Repository Pattern
• Decouple the domain model from database/infrastructure.
• Apply Dependency Inversion Principle (DIP):
• Domain should not depend on ORM or database.
• ORM/database should depend on the domain.
Repository acts as an in-memory-like abstraction over storage.

2. Why We Need It
• Domain model must remain pure, testable, and persistence-ignorant.
• Avoid embedding ORM details inside Entities/Value Objects.
• Unit tests stay fast because repositories can be replaced with fakes.

3. Traditional ORM Problem


Declarative ORMs (SQLAlchemy, Django) require domain classes to:
• inherit from ORM base classes
• define DB columns inside model
• This makes the model depend on the database → violates DIP.

4. Solution: Invert the Dependency


Use SQLAlchemy classical mapping:
• Tables defined separately
• Domain classes mapped explicitly to tables
• ORM imports the domain → domain stays clean.

5. Repository Interface (Abstraction)


Minimal contract:
• add(entity)
• get(reference)
• list()

Provides a consistent API for accessing domain objects.


Represents a collection-like interface.

6. AbstractRepository (ABC)
• Defines required repository methods.
• Helps document the expected behavior.
• ABCs optional in Python; can rely on duck typing.

7. Concrete Repository: SqlAlchemyRepository


Responsibilities:
• Wrap a SQLAlchemy session.
• Implement add, get, and list using ORM operations.
• Commit is not handled inside the repo (left to caller).
8. Benefits of the Repository Pattern
• Domain does not know about SQL, ORM, schema, or sessions.
• Storage method can be swapped (SQL → NoSQL → CSV → in-memory).
• Tests can use FakeRepository instead of real DB.

9. Fake Repository (for Testing)


• A lightweight, in-memory implementation.
• Stores domain objects in a set.
• Allows fast, reliable, infrastructure-free tests.

10. Repository Tests


Two types:
• Integration tests verifying ORM + repository work together.
• Unit tests using FakeRepository to test domain services.

Tests typically check:


• Saving a batch
• Retrieving a batch with allocations
• Mapping between DB rows and domain objects

11. Architectural Context


Repositories sit at the boundary between:
Domain (core)
Infrastructure (ORM/DB)
Fits onion architecture / hexagonal architecture / ports & adapters.

12. Trade-Offs
Pros:
• Clean domain model
• Testability
• Flexibility to change storage backend

Cons:
• Extra abstraction layer
• More code to write/maintain (repositories + mappings)

13. Repository in a Web Endpoint


• Endpoint calls repo to retrieve batches.
• Domain service performs allocation.
• Repo used again to persist changes.
• [Link]() done outside repo.
What Is a Port and What Is an Adapter, in Python?
1. Core Idea
Ports and adapters are concepts used to achieve dependency inversion.
Goal: Core application (domain) depends only on abstractions, never on infrastructure.

2. What Is a Port?
A port is the interface or protocol that defines how the application expects to interact with
the outside world.
In strongly typed languages → a formal interface.

In Python → usually one of:


• An Abstract Base Class (ABC)
• A PEP 544 protocol
• A duck-typed interface (“object with these methods”)

Example in this chapter:


AbstractRepository = the port.

3. What Is an Adapter?
• An adapter is an implementation of the port.
• It connects the core logic to an external system (DB, message bus, API, filesystem).
• Adapters satisfy the port’s interface but contain infrastructure details.

Example in this chapter:


SqlAlchemyRepository = adapter using SQLAlchemy
FakeRepository = in-memory fake adapter for tests

4. Why Python Makes Ports Ambiguous


No built-in interface keyword.
Any object with the required methods can be treated as the port (duck typing).

Therefore:
• The port is the expected method signatures.
• The adapter is any implementation matching them.

5. Practical Rules
Port = “what the domain expects”
Adapter = “how it is actually done”
Domain imports the port, never the concrete adapter.
Adapters depend on domain, not the other way around.

6. Benefits
• Decouples domain from infrastructure.
• Easy to replace adapters (SQL, NoSQL, filesystem, fake, stub).
• Enables pure in-memory testing with fake repositories.

A Brief Interlude: On Coupling and Abstractions


1. Why Abstractions Matter
Abstractions hide messy details and reduce coupling.

Good abstractions let us:


• Test logic without touching real I/O
• Change infrastructure without breaking business logic
• Keep code extensible (e.g., dry-run mode, switching storage backends)

2. Coupling
• Local Coupling (Good)
• Components that belong together and collaborate closely → high cohesion.
• Helps clarity and readability.

Global Coupling (Bad)


• Unrelated components depend on each other.
• Makes changes risky and expensive.
• Leads to “Ball of Mud” architecture if not controlled.

3. How Abstractions Reduce Coupling


Insert a simple interface between two complex subsystems.
System A depends on the abstraction, not the concrete messy system B.
Changing B no longer forces changes in A.

4. Testability Through Abstraction


Tight coupling between domain logic and I/O leads to:
• Slow, complex tests
• Hard-to-set-up fixtures
• Difficulty testing edge cases

Solution
Separate:
• Reading state (I/O)
• Deciding actions (pure business logic)
• Applying changes (I/O)
Isolate the functional core so it can be unit-tested with simple Python data structures.

5. Functional Core, Imperative Shell (FCIS)


Functional Core
Pure logic, no side effects
Deterministic, easy to test
e.g., determine_actions()

Imperative Shell
Reads/writes external state
e.g., reading directories, copying files

6. Dependency Injection for Integration Testing


Pass dependencies (reader, filesystem) into functions instead of calling modules directly.
Allows edge-to-edge testing without touching real I/O.
Use fakes to capture effects (spy objects).

7. Mocks vs Fakes
Mocks
• Verify behavior (how something is called)
• Can lead to brittle tests tied to implementation

Fakes
• Lightweight, functional stand-ins
• Verify state/output, not behavior
• More aligned with “classic TDD”

8. Why Avoid Excessive Mocking


Doesn’t improve design or extensibility
Couples tests to internal implementation
Harder to read and maintain
Abstractions + fakes lead to clearer, more meaningful tests

9. Heuristics for Finding Good Abstractions


Can messy state be represented as a simple Python data structure?
Where can you place a seam to isolate complexity?

Can responsibilities be split into:


• Data gathering
• Pure decision logic
• Side-effect execution?
• What is the core logic vs infrastructure?

10. Key Takeaway


Designing for testability == Designing for extensibility.
Clean abstractions let you change infrastructure, write fast tests, and keep the system
maintainable.

Flask API and Service Layer


1. Why Introduce a Service Layer?
The Flask handler was doing too much orchestration (fetching data, validation, domain
calls, commits).

Leads to:
• Messy controllers
• Too many slow E2E tests
• Logic duplicated across adapters

Service Layer provides a single API for use cases, separating:


• Web concerns
• Orchestration
• Domain logic

2. Roles of Each Layer


Flask (Adapter / Entrypoint)
Only handles:
• HTTP parsing
• Request/response formatting
• Status codes
• Session creation
Delegates business workflow to the service layer.

Service Layer (Application Service)


Implements a use case (e.g., allocate order).
Responsibilities:
• Fetch data from repository
• Validate inputs relative to current state
• Call domain services/entities
• Persist changes (commit)
Depends on AbstractRepository, not on concrete DB.

Domain Model
Contains business logic.
Domain services = stateless functions encapsulating domain rules.

3. Why Not Test Everything E2E?


E2E tests are slow + brittle.
Without service layer, error validation must be tested via HTTP.
After adding service layer:
• Only two E2E tests remain (happy + unhappy path).
Most workflow tests become fast in-memory service tests using FakeRepository.

4. FakeRepository & FakeSession


FakeRepository:
• In-memory collection for batches.
• Implements same interface as AbstractRepository.

FakeSession:
• Mimics .commit() and removes DB dependency.
• Allows high-gear, fast testing of full workflows.

5. Structure of a Typical Service Function


A service function usually does:
• Load aggregates via repository
• Validate use-case-specific rules
• Call domain service (e.g., [Link])
• Commit changes
• Return simple/primitive results

6. Benefits of Service Layer


Architecture Benefits
Thin controllers, orchestration moved out
Clear “ports” for interacting with the domain
Domain model remains clean, self-contained
Works well with Repository + Unit of Work patterns

Testing Benefits
Most workflow logic testable with fakes
E2E tests limited to minimal surface
Faster tests → better TDD flow

7. Dependency Inversion in Action


Service layer depends on AbstractRepository (port)
Concrete implementations:
• Tests → FakeRepository
• Production → SqlAlchemyRepository
• High-level code does not depend on database details.

8. Error Handling Goes in the Service Layer


Service layer checks:
• Invalid SKU (not found in repository)
• Domain-level exceptions (e.g., OutOfStock)
• Flask handler only converts errors → HTTP responses.

9. Project Structure (Suggested Layout)


domain/ # Entities, value objects, domain services
service_layer/ # Use case orchestration, UoW (later)
adapters/ # Repositories, ORM, external clients
entrypoints/ # Flask or CLI handlers
tests/ # unit/, integration/, e2e/

10. Key Distinctions


• Domain Service vs Service Layer
• Domain Service
• Pure business logic, stateless
E.g., allocate algorithm

Service Layer (Application Service)


• Coordinates workflow
• Calls domain services
• Handles repos, commits, validations

11. Trade-offs
Pros
• Clean controller logic
• Clear boundary for use cases
• Enables fast tests
• Helps keep domain rich and isolated

Cons
• More layers → more indirection
• Overusing service layer risks anemic domain if it steals too much logic

TDD in High Gear and Low Gear


1. Purpose of This Chapter
Show how adding a service layer changes the testing strategy.
Demonstrate how to build a healthy test pyramid.
Explain when to test at domain level vs service level.

2. The Test Pyramid in Practice


After introducing the service layer:
• Unit tests: Majority (fast, isolated)
• Integration tests: Fewer (DB, ORM, infrastructure)
• E2E tests: Minimal (1 happy path + 1 unhappy path per feature)
• Goal: Fast feedback, yet still catch broken integrations.

3. Moving Tests Up the Stack


Why migrate domain tests → service-layer tests?
Tests at domain level:
• Give good design feedback
• Act as “documentation”
• But tightly coupled → brittle during refactors

Service-layer tests:
• Less coupling
• More stable when domain internals change
• Cover full workflows
• Balance is required.

4. Key Insight: Tests Are Coupling


Every test holds the code in a fixed shape.
More low-level tests = harder refactoring.
Prefer higher-level tests once domain behavior stabilizes.

5. High Gear vs Low Gear Testing


Low Gear
• Write tests directly against domain objects.
• Use early in a project or when solving tricky domain rules.
• Provides max design feedback.

High Gear
• Write tests at the service layer.
• Faster, broader coverage.
• Use for new features, normal workflow work.
• Less fragile.

Metaphor:
Low gear → start/steep hills → careful detailed control
High gear → cruising → speed and efficiency

6. Fully Decoupling Service Tests


To remove domain dependencies from service-layer tests:
• Make service functions use primitive types (str, int, date).
• Avoid constructing domain objects in tests.
Introduce missing services (e.g., add_batch) so tests only use the public service API.
Use fakes for repo + session.

Result:
• Tests rely only on the service API, not domain internals.
• Domain can be freely refactored.

7. Improving E2E Tests


Add missing API endpoints to avoid using DB fixtures directly.
E2E tests should only interact with the HTTP API.
No SQL, no domain objects, no repo instantiation.

8. Rules of Thumb for Test Types


1. One E2E Test per Feature
Tests integration of all layers.
One happy path + one shared unhappy-path test.

2. Majority of Tests at Service Layer


Best balance of:
• Coverage
• Speed
• Stability
• Use fakes for I/O.

3. Keep a Few Domain Unit Tests


• For deeply domain-specific logic.
• Delete them later if covered by service tests.

4. Error Handling = Its Own Feature


Centralized error handling means:
• Service tests cover unhappy paths
• Only a single universal E2E unhappy-path test is needed

9. Best Practices
Express service layer in primitive types, not domain objects.
Let service layer represent the official application API.
Avoid leaking domain construction into tests.
Use helper factories or higher-level services to avoid domain coupling.
Unit of Work (UoW) Pattern
UoW is an abstraction for atomic operations on persistent storage.
Works closely with Repository and Service Layer patterns.
Ensures data consistency by treating a set of operations as a single transaction.

Benefits
Atomicity: All changes succeed or fail as a unit.
Stable Snapshot: Objects loaded don’t change mid-operation.
Simplified Persistence: Centralized access to repositories.
Testable: Easier to swap real DB sessions with fake UoWs for testing.
Dependency Inversion: Service layer depends on abstraction, not concrete DB session.

Core Interface
AbstractUnitOfWork
class AbstractUnitOfWork([Link]):
batches: AbstractRepository

@[Link]
def commit(self):
raise NotImplementedError

@[Link]
def rollback(self):
raise NotImplementedError

def __exit__(self, *args):


[Link]() # default rollback if commit not called

.batches → repository access


commit() → persist changes
rollback() → discard changes
Context manager ensures rollback on exceptions.

Concrete Implementation (SQLAlchemy Example)


class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
def __enter__(self):
[Link] = self.session_factory()
[Link] = SqlAlchemyRepository([Link])
return self

def __exit__(self, *args):


super().__exit__(*args)
[Link]()

def commit(self):
[Link]()

def rollback(self):
[Link]()

Manages DB session lifecycle.


Can swap session factory for tests (e.g., SQLite vs Postgres).

Testing
Use FakeUnitOfWork with in-memory data for unit tests.
Avoid mocking third-party libraries; mock what you own.
Commit/rollback behavior is explicitly tested to ensure atomicity.

Service Layer Usage


def add_batch(ref, sku, qty, eta, uow):
with uow:
[Link](Batch(ref, sku, qty, eta))
[Link]()

def allocate(orderid, sku, qty, uow):


line = OrderLine(orderid, sku, qty)
with uow:
batches = [Link]()
batchref = allocate(line, batches)
[Link]()
return batchref

Service functions depend only on UoW abstraction.


Explicit commit ensures safe-by-default behavior.

Atomic Examples
Reallocate
with uow:
[Link](line)
allocate(line)
[Link]()
Change Batch Quantity
with uow:
batch.change_purchased_quantity(new_qty)
while batch.available_quantity < 0:
batch.deallocate_one()
[Link]()
Failures in the middle prevent partial commits.

Design Considerations
Explicit vs Implicit commit: Prefer explicit commit for safety.
Integration with ORM: SQLAlchemy Session already implements UoW; wrapping it narrows
interface and simplifies tests.
Repositories: UoW is a convenient place to collect all repositories.
Context Managers: Pythonic way to define transactional scope.

Key Takeaways
UoW centralizes atomic changes in a single unit.
Works as a collaborator with Repositories and Services.
Ensures consistency, testability, and safer code.
Explicit commit/rollback makes behavior predictable.
Supports dependency inversion, reducing coupling to DB details.

Domain Modeling vs. Spreadsheets


Spreadsheets are simple and familiar but don’t scale for enforcing business rules and
constraints.
Domain model: explicitly enforces constraints (rules) and invariants (conditions always
true).

Examples:
Hotel booking: no double bookings → invariant: room can have only one booking per night.
Order allocation: one order line → one batch → invariant: cannot allocate more than
available stock.
Invariants, Constraints, and Consistency
Constraint: limits possible states (e.g., cannot oversell stock).
Invariant: condition always true (e.g., stock ≥ 0).
Concurrency complicates enforcing invariants → need strategies like locks or aggregates.

Aggregate Pattern
Aggregate: cluster of related objects treated as a single unit for consistency.
Root entity: single entrypoint for modifying the aggregate (e.g., Product for its Batches).

Benefits:
Simplifies reasoning about consistency.
Prevents concurrent writes on related objects.
Example: Cart aggregate manages all Items as a single unit.

Guidelines:
Only modify one aggregate at a time.
Aggregate defines consistency boundary.
Aggregate choice affects concurrency and performance.

Choosing an Aggregate
Pick smallest set of objects that need consistent state together.
Example: Product as aggregate for all batches of a SKU.
Avoid large boundaries (e.g., Warehouse or Shipment) that limit concurrency.

Optimistic Concurrency Control


Assumes conflicts are rare → let operations proceed, detect conflicts via version numbers.
Version number: increments on every update to detect concurrent modifications.
On conflict, retry operation.
Optionally: use transaction isolation levels or database locks for pessimistic control.

Implementation Notes
Aggregate encapsulates all updates: only root entity’s methods modify internal objects.
ORM may need tweaks to load all related objects automatically.

Optimistic locking example:


class Product:
def __init__(self, sku, batches, version_number=0):
[Link] = sku
[Link] = batches
self.version_number = version_number

def allocate(self, line):


batch = next(b for b in sorted([Link]) if b.can_allocate(line))
[Link](line)
self.version_number += 1
return [Link]

Database strategies:
Optimistic: version numbers, retries.
Pessimistic: SELECT FOR UPDATE locks rows.
Testing Concurrency
Simulate concurrent transactions using threads.

Assert:
• Only one allocation succeeds per aggregate.
• Version number increments correctly.
• Use isolation levels (e.g., REPEATABLE READ) to control behavior.

Trade-offs
Trade-offs Pros Cons
Simplifies reasoning about
Aggregates New concept to learn for developers
consistency
Improves performance vs. locking Must handle eventual consistency
entire system between aggregates
Aggregate manages all related
Rigid single-aggregate modification rule
updates

Key Takeaways
Aggregates = entrypoints into domain model.
Consistency boundaries prevent race conditions and maintain invariants.
Concurrency handling is critical for real-world systems.
Patterns add complexity; not necessary for simple CRUD apps.

Part II. Event-Driven Architecture


Core Idea
Focus on messaging between modules rather than internal details.
Event-driven design helps decouple components and manage workflows across
distributed systems.

Key Patterns & Concepts


Domain Events
Represent facts about something that happened in the domain.
Events are data-only objects (usually dataclass) with no behavior.
Keep domain model free from infrastructure concerns (e.g., email/SMS).

Message Bus
Routes events to appropriate handlers.
Acts as publish-subscribe system:
HANDLERS = {EventType: [handler1, handler2]}
[Link](event)
Keeps handlers decoupled from core logic.
Unit of Work (UoW)
Tracks changes in aggregates via .seen.
Publishes events to message bus after commit.
Can eliminate the need for service layer to handle events directly.

Service Layer
Orchestrates workflows but should remain free from side effects.
Three ways to handle events:
Service layer raises events → passes to message bus.
Domain model raises events → service layer passes to bus.
UoW collects events → publishes to bus automatically (cleanest).

Single Responsibility Principle


Each class/function should have one reason to change.
Avoid mixing domain logic with side effects like email.

Benefits of Event-Driven Architecture


Decouples primary use cases from secondary concerns.
Supports eventual consistency across aggregates.
Improves testability and observability.
Makes it easier to handle workflows expressed as: "When X, then Y".

Trade-offs / Challenges
Pros Cons
Separates responsibilities Adds complexity to the system
Handlers decoupled from core logic Event-handling hidden, can feel “magic”
Synchronous execution may hurt
Events model domain concepts
performance
Risk of circular dependencies & infinite
Facilitates eventual consistency
loops
Can replace infrastructure easily (email →
Harder to trace complete workflow
SMS)

Best Practices
• Keep domain model focused on business rules.
• Use dataclasses for events.
• Let UoW or message bus handle event propagation.
• Avoid using exceptions for control flow; use events instead.
• Test events separately from core business logic.
Going to Town on the Message Bus
Message Bus as Core Architecture
Before: Events were optional side-effects.
After: Everything flows through the message bus, making the app fundamentally an event
processor.

Why Change?
Real-world systems are unpredictable (stock damage, missing documentation, shortages).
Business rules may require dynamic reallocation (e.g., batch quantity changes →
deallocate → reallocate).
Message bus supports Single Responsibility Principle (SRP) and transaction control.

Event-Driven Design
All flows → events:
• API calls → treated as events.
• Internal domain changes → events handled like API events.

Example:
AllocationRequired → handled by allocate().
BatchCreated → handled by add_batch().
BatchQuantityChanged → handled by change_batch_quantity().

Refactoring Steps
Convert service functions → event handlers.
End-to-end tests with events.
Implement new handlers that emit events for further processing.
Message bus collects new events from the Unit of Work (UoW).

Event & Handler Structure


Events: simple dataclasses representing inputs/internal messages.

@dataclass
class AllocationRequired(Event):
orderid: str
sku: str
qty: int

Handlers: receive (event, uow) and perform logic → may raise new events.
def allocate(event, uow):
line = OrderLine([Link], [Link], [Link])
...
Handlers replaced previous service-layer APIs.
Message Bus Implementation
Maintains queue of events, invokes handlers, collects new events from UoW.
Returns results if necessary (temporary hack for API reads).
def handle(event, uow):
queue = [event]
results = []
while queue:
evt = [Link](0)
for handler in HANDLERS[type(evt)]:
[Link](handler(evt, uow))
[Link](uow.collect_new_events())
return results

Unit of Work Changes


collect_new_events() replaces publish_events().
UoW no longer actively pushes events; message bus does it.

Repository Changes
Added query get_by_batchref() for batch-based lookups.
Works for real and fake repositories for testing.

Implementing New Requirement (BatchQuantityChanged)


Event triggers handler → adjusts batch quantity → deallocates if necessary → emits
AllocationRequired events.
Domain model handles logic and publishes events.
class Product:
def change_batch_quantity(self, ref, qty):
batch = next(b for b in [Link] if [Link] == ref)
batch._purchased_quantity = qty
while batch.available_quantity < 0:
line = batch.deallocate_one()
[Link](AllocationRequired([Link], [Link], [Link]))

Testing Strategy
Edge-to-edge tests: use real message bus to verify full workflow.
Isolated tests: use fake message bus to test a single handler and emitted events.

Pros & Cons of Full Event-Driven Approach


Pros Cons
Handlers & services unified → simpler Timing can be unpredictable
Duplication between model &
Events = clear data structures for inputs
events
Pros Cons
Handles complex flows without architecture
Slightly more complex infrastructure
changes

Key Takeaways
Treat everything as an event: input, internal changes, side effects.
Message bus decouples how work is done from who does it.
New requirements often fit naturally into this pattern: add event → add handler → update
domain → test.
SRP maintained: each handler does one thing; events orchestrate work.

Commands vs Events
Feature Event Command
Imperative verb (e.g.,
Name Past tense (e.g., OrderAllocated)
AllocateStock)
Record something that has already Request that an action be
Purpose
happened performed
Error
Fail independently Fail noisily (bubble up to caller)
handling
Recipient Broadcast to all listeners Sent to a single handler

Command Examples
Allocate(orderid, sku, qty) → replaces AllocationRequired event.
CreateBatch(ref, sku, qty, eta) → replaces BatchCreated.
ChangeBatchQuantity(ref, qty) → replaces BatchQuantityChanged.

Message Bus Handling


handle(message) dispatches based on type:
Events:
• Can have multiple handlers.
• Errors are logged but processing continues.
• Non-blocking; may fail independently.

Commands:
• Single handler only.
• Errors propagate immediately.
• Fail fast; ensures transactional boundaries.
Handlers dictionary:
EVENT_HANDLERS = {EventType: [handler1, handler2]}
COMMAND_HANDLERS = {CommandType: handler}

Error Handling & Consistency


Aggregates define consistency boundaries.
Example: Product aggregate ensures stock allocation is consistent.
Commands must succeed fully; events can fail without breaking core functionality.
Using events for secondary actions (notifications, emails) prevents business-critical failure
if non-essential tasks fail.

Example: VIP customer scenario


OrderCreated → command persists order.
UpdateHistory → event records customer history.
CongratulateVIP → event sends email.
Only the command creating the order is critical; other steps can fail and be retried.

Retries for Event Handling


Use tenacity or similar for transient failures:
for attempt in Retrying(stop=stop_after_attempt(3), wait=wait_exponential()):
handler(event, uow=uow)
Ensures eventual consistency without blocking core operations.

Pros & Cons:


Pros Cons
Clear separation of critical vs secondary tasks Subtle differences can cause confusion
Explicit intent in commands Requires good monitoring & logging
Allows isolated failures System may be harder to reason about

Event-Driven Architecture & Microservices Integration


1. Motivation
Microservices need to communicate beyond HTTP APIs.
Using events decouples services, avoids the Distributed Ball of Mud, and supports
eventual consistency.
Events handle state changes; commands represent intentions.

2. Problems with Noun-Based Services


Noun-based splitting (Orders, Batches, Customers) often leads to:
Complex dependency graphs.
Temporal coupling: multiple services must succeed together.
Error cascades when one service fails.

Connascence types:
• Execution (strong local coupling)
• Timing (operations must occur in order)
• Name (weaker coupling, just agree on event names/fields)

3. Temporal Decoupling
Focus on verbs (actions/processes) rather than nouns.
Each service acts as a consistency boundary.
External services interact via commands and events:
Commands: request action, succeed/fail atomically.
Events: notify what happened, handled asynchronously, may fail independently.

4. Using Asynchronous Messaging


Services listen for incoming events and publish outgoing events.

Reduces temporal coupling:


• Order placement doesn’t wait for allocation to complete.
• Failures can be handled locally without affecting the whole flow.
• Example: BatchQuantityChanged triggers reallocation → publishes Allocated event.

5. Redis Pub/Sub as Message Broker


Acts as an adapter between services:
Event Consumer: subscribes to channels, converts messages → commands → message
bus.
Event Publisher: converts domain events → messages → Redis channels.

Example:
# Incoming
ChangeBatchQuantity event → Command → Message Bus → [Link]()

# Outgoing
[Link]() → Allocated event → Redis publish → downstream services

6. End-to-End Testing
Tests verify the full flow: API → internal processing → external events.
Use retry loops (e.g., tenacity) to handle async message arrival.

Key channels:
Input: change_batch_quantity
Output: line_allocated
7. Internal vs External Events
Internal events: processed within the service, not published externally.
External events: published to other services or systems.
Apply validation for outbound events to ensure consistency.

8. Trade-offs of Event-Driven Integration


Pros Cons
Avoids distributed “big ball of mud” Flows harder to see/debug
Services decoupled, easier to change Eventual consistency introduces new complexity
Can add new services without Must consider message reliability (at-least-once vs
breaking existing ones at-most-once delivery)

Key takeaways:
Event-driven systems reduce synchronous dependencies and temporal coupling.
Commands are about intent, events are about facts.
Redis/Kafka/RabbitMQ are common brokers for asynchronous messaging.
Use retries and logging for reliability.
Focus on verbs (processes) rather than nouns (data objects) for service boundaries.

CQRS (Command-Query Responsibility Segregation)


CQRS Core Idea
Reads and writes are different → treat them differently.
Writes involve complex domain logic; reads often simple and cacheable.
Separate write model (enforces business rules) from read model (optimized for queries).

Why Separate Reads and Writes


• Domain model is designed for writes, not for queries.
• Users’ read requests can tolerate slightly stale data, writes cannot.
• All distributed systems have potential for stale reads, so perfect consistency is
often unnecessary.

Read vs Write Characteristics


Feature Read Side Write Side
Behavior Simple read Complex business logic
Cacheability Highly cacheable Uncacheable
Consistency Can be stale Must be transactional

CQS and Web Apps


CQS (Command-Query Separation): functions either modify state (commands) or return
data (queries), not both.
Post/Redirect/Get pattern exemplifies CQS:
• POST → modify data
• Redirect → GET to read data
• Options for Implementing Read Views

Using repositories
Pros: Reuse domain model and DB config.
Cons: Can be clunky; involves looping/filtering in code.

Using ORM
Pros: Fine control with query language.
Cons: Complexity; potential SELECT N+1 problem.

Hand-rolled SQL
Pros: Efficient for complex queries.
Cons: Adds SQL maintenance overhead; tightly coupled to schema.
Separate read store (denormalized, e.g., Redis)
Pros: Optimized for reads, easy to scale horizontally.
Cons: Extra complexity; must keep read model in sync with events.
Keeping Read Models Up-to-Date

Use event handlers:


• Allocated → add to read model
• Deallocated → remove from read model
• Can use any backend (SQL table, Redis, etc.)
• Rebuilding read models from write model is straightforward if needed.

Key Takeaways
Domain models excel at writing, not reading.
For complex domains, a separate read model is often justified.
Event-driven updates allow read model flexibility and scalability.
Trade-offs: simpler approach (repository/ORM) vs. highly scalable (read store with events).

Dependency Injection (DI) and Bootstrapping


1. Dependency Injection (DI)
Definition: Technique to provide dependencies to a component rather than hardcoding
them inside it.
Python Context: Often avoided due to dynamic typing and ease of monkeypatching.
Benefits:
• Makes testing easier (swap real dependencies with fakes/mocks).
• Promotes dependency inversion principle: depend on abstractions, not concrete
implementations.
• Explicit is better than implicit (Zen of Python).

2. Implicit vs Explicit Dependencies


Implicit: Import and use directly (import email; [Link]()).
Easy, “Pythonic” for small apps.
Makes tests harder due to tight coupling; requires [Link].
Explicit: Pass dependencies as parameters.
Easier to override in tests.
Example: def allocate(cmd, uow): ...

3. Composition Root / Bootstrap Script


Composition Root: Single place to assemble the application, inject dependencies, and
initialize components.

Bootstrap Script Responsibilities:


• Declare default dependencies (allow overrides).
• Initialize system-wide resources (e.g., ORM, logging).
• Inject dependencies into handlers.
• Return core application object (e.g., message bus).

4. Manual Dependency Injection


Using closures/lambdas:
allocate_composed = lambda cmd: allocate(cmd, uow)

Using [Link]:
from functools import partial
allocate_composed = partial(allocate, uow=uow)
Class-based DI: Convert handlers to callable classes with dependencies injected via
__init__.

class AllocateHandler:
def __init__(self, uow):
[Link] = uow
def __call__(self, cmd):
...
allocate = AllocateHandler(uow)

5. Dependency Injection in Message Bus


MessageBus:
Receives already-injected handlers.
Handles commands/events using injected handlers.
Removes the need for global/static handlers.
Handler invocation: Only the message (command/event) is passed; dependencies already
bound.

6. Testing with DI
Unit Tests: Use fake dependencies like FakeUnitOfWork or FakeNotifications.
Integration Tests: Override bootstrap defaults (e.g., in-memory DB, MailHog for emails).
Benefits: No need to mock everywhere; setup is centralized.

7. Building Adapters
When to use ABCs: For complex dependencies (e.g., UoW, notifications API).

Implementation:
• Define abstract class (AbstractNotifications).
• Implement concrete class (EmailNotifications).
• Provide fake implementation for tests (FakeNotifications).
• Integration Testing: Use near-real implementations (e.g., MailHog for email,
SQLite/Postgres for DB).

8. Recap / Steps for DI & Bootstrap


Define API with an abstract base class (ABC) if needed.
Implement the real dependency.
Implement a fake for unit tests.
Use a near-real version for integration/Docker environment.
Bootstrap script assembles everything and injects dependencies.
Test and profit.

1. Starting with Legacy Systems


Identify the problem: maintainability, performance, bugs.
Prioritize improvements based on business value; link refactoring to feature work
(“architecture tax”).
Start small, step by step—no need for perfect implementation initially.

2. Separating Responsibilities
Big balls of mud: code lacks clear boundaries; logic is everywhere.
Introduce a service layer to centralize orchestration.

Define use cases with imperative names; each should:


• Start a transaction
• Fetch required data
• Check preconditions
• Update the domain model
• Persist changes
Avoid long chains of manager calls; use a Unit of Work (UoW) for transactions.
Duplication is okay temporarily; refactor later.

3. Aggregates & Bounded Contexts


Identify consistency boundaries (aggregates) and reduce direct object links.
Replace bidirectional links with identifiers to simplify object graphs.
Update one aggregate per transaction; for multi-aggregate changes, consider events for
eventual consistency.
Separate read vs. write models (CQRS) to simplify performance and consistency.

4. Event-Driven Systems & Microservices


Strangler Fig pattern: gradually replace old system edges with new functionality.

Event interception:
• Raise events for state changes
• Build new system that consumes events
• Replace old system gradually
Start with a “walking skeleton”: minimal working system to test infrastructure.
CQRS and message buses can simplify orchestration but microservices aren’t mandatory.

5. Convincing Stakeholders
Align business and engineering language via domain modeling.
Use techniques like event storming and CRC modeling to collaborate.
Solve specific problems first; start small.

6. Practical Advice
Implement incrementally; don’t refactor everything at once.
Copy-paste for short-term extraction; clean later.
One use case can call another, but prefer message bus/event-driven decoupling.
Use read models for data from other aggregates.
Idempotency and reliable messaging are critical for production.

7. Pitfalls & Footguns


Redis pub/sub is not reliable; use proper messaging systems (RabbitMQ, Kafka,
EventStore).
Small, focused transactions are easier to monitor and retry.
Event schemas evolve—document and version them.

8. Recommended Further Reading


Clean Architectures in Python – Leonardo Giordani
Enterprise Integration Patterns – Hohpe & Woolf
Monolith to Microservices – Sam Newman
Greg Young on event sourcing & versioning

You might also like