A reference e-commerce web application demonstrating architectural patterns for medium-complexity applications developed by small teams. This project serves as a practical example of how to structure a Domain-Driven Design (DDD) Modular Monolith with Hexagonal Architecture and Application Layer Orchestration.
There is also a sister ref app (ref-layered-mvt-active-record) that demonstrated a layered architecture using active record.
This application is intentionally kept simple enough to understand while demonstrating solutions to common web application problems. Use it as a reference when building real applications that need clean architecture, maintainability, and testability.
For detailed architecture documentation, see specs/ARCHITECTURE.md.
The standard Django approach — fat models, ModelSerializers, ModelViewSets — is excellent for rapid prototyping and small applications. It lets you expose a full REST API for a model in a handful of lines. However, that convenience comes from tight coupling: serializers blur validation, deserialization, and persistence into a single class that depends on both Django REST Framework and the ORM. ORM operations end up spread across views, serializers, and model methods, making it hard to reason about where data access happens. This project intentionally avoids that trade-off. Validation lives in the domain layer (aggregate factory methods and validation functions). Deserialization is just reading fields from the request dict in the view. Persistence is encapsulated in repositories behind abstract interfaces. Serialization out is a simple recursive to_dict() function that converts dataclasses to dictionaries at the infrastructure boundary. The result is that the core business logic has zero framework dependencies — it's pure Python dataclasses and functions that can be tested without Django, ported to another framework by writing new adapters, and reasoned about without understanding DRF internals. Django REST Framework is still a dependency, but only for its HTTP utilities: @api_view, Request, Response, and status codes.
- Backend: Python 3.12+ / Django 6.0 / Django REST Framework
- Frontend: TypeScript / React 19 / Chakra UI
- Database: SQLite (development)
- Python 3.12+
- Node.js 18+
python -m venv .venv
source sourceme
cd backend
pip install -r requirements.txt
python manage.py migrate
python manage.py runserverTo enable real email sending (e.g., for incident notifications), configure Gmail SMTP:
-
Copy the example environment file:
cp backend/.env.example backend/.env
-
Edit
backend/.envwith your Gmail credentials:EMAIL_HOST_USER=your-email@gmail.com EMAIL_HOST_PASSWORD=your-app-password -
Important: You need a Gmail App Password, not your regular password:
- Go to Google App Passwords
- Generate an app password for "Mail"
- Use that 16-character password in
EMAIL_HOST_PASSWORD
Without these settings, emails are printed to the console (development mode).
cd frontend
npm install
npx playwright install
npm run dev- Username:
admin - Password:
admin
This reference application demonstrates solutions to common architectural challenges in web applications:
Problem: Business logic gets tangled with framework code, database access, and HTTP handling, making the codebase hard to test and maintain.
Solution: Layered Architecture with clear boundaries:
| Layer | Responsibility | Dependencies |
|---|---|---|
| Domain | Business logic, entities, validation | None (pure Python) |
| Application | Use case orchestration, authorization | Domain only |
| Infrastructure | Framework integration, persistence, APIs | All layers |
The domain/ directory contains zero Django imports. Business rules can be tested without spinning up a web server or database.
Problem: Over-reliance on framework features creates vendor lock-in and makes the codebase harder to port or test.
Solution: Hexagonal Architecture (Ports & Adapters)
- Ports: Abstract interfaces defined in domain/application layers (e.g.,
RepositoryInterface,FeatureFlagPort,EmailPort) - Adapters: Framework-specific implementations in infrastructure (e.g.,
DjangoProductRepository,DjangoFeatureFlagAdapter)
The application layer depends only on interfaces. Swap Django for Flask by writing new adapters—the domain and application layers remain unchanged.
Problem: Operations spanning multiple aggregates (e.g., "add item to cart" affects both Cart and Product) need coordination while maintaining consistency.
Solution: Application Layer Orchestration with Unit of Work
def add_item(self, product_id: str, quantity: int, user_context: UserContext) -> Cart:
# Lock product to prevent concurrent stock changes
product = self.uow.get_product_repository().get_by_id_for_update(product_id)
# Coordinate both aggregates
cart.add_item(product_id, product.name, product.price, quantity)
product.reserve_stock(quantity)
# Single transaction commits both changes
self.uow.get_cart_repository().save(cart)
self.uow.get_product_repository().save(product)All changes within a request happen in a single database transaction, ensuring strong consistency.
Problem: Aggregates that directly reference each other become tightly coupled and hard to evolve independently.
Solution: Reference by ID + Snapshot Pattern
Aggregates never embed or import other aggregates. They reference other aggregates by ID only:
@dataclass(frozen=True)
class CartItem:
product_id: UUID # Reference by ID, not embedded Product
product_name: str # Snapshot: copied when item added
unit_price: Decimal # Snapshot: price at time of additionIf a product's price changes later, cart items retain the original price. If a product is deleted, order history remains intact.
Problem: Cross-cutting concerns like audit logging pollute domain logic. Tightly coupled event handling creates fragile systems.
Solution: Domain Events with Post-Commit Dispatch
Aggregates raise events internally:
def reserve_stock(self, quantity: int, actor_id: str) -> None:
self.stock_quantity -= quantity
self._raise_event(StockReserved(product_id=self.id, quantity=quantity, actor_id=actor_id))Events are dispatched after the transaction commits. Handlers (sync or async) process them independently:
- Sync handlers: Audit logging (fast, critical)
- Async handlers: Email notifications (slow, non-blocking)
Handler failures are logged but don't break the business operation.
Problem: Reporting and dashboard queries need to aggregate data from multiple aggregates. Forcing these through the domain layer is awkward and inefficient - aggregates are designed for write consistency, not read optimization.
Solution: Simple Query Separation - treat reads and writes differently
Commands (writes) go through the domain:
API → Application Service → Aggregates → Repository → DB
Cross-aggregate queries bypass the domain:
API → Reader → DB (direct joins/aggregations)
Example: Product report combining data from Product, Cart, and Order aggregates:
class DjangoProductReportReader(ProductReportReader):
def get_report(self, query: ProductReportQuery) -> list[ProductReportItem]:
# Single query with subqueries aggregating from related tables
queryset = ProductModel.objects.annotate(
total_sold=Coalesce(Subquery(order_items_sum), 0),
currently_reserved=Coalesce(Subquery(cart_items_sum), 0),
)
return [self._to_report_item(p) for p in queryset]Benefits:
- Queries don't need aggregates - no invariants to enforce for reads
- Efficient - single optimized database query with joins
- Simpler than full CQRS - no separate read database or event projections
- Clean separation - domain stays focused on business rules
Problem: Authentication concerns (sessions, tokens, user objects) leak into domain logic, coupling it to the web framework.
Solution: UserContext Pattern
The domain layer defines a framework-agnostic UserContext:
@dataclass(frozen=True)
class UserContext:
user_id: UUID
username: str
role: Role # ADMIN or CUSTOMER
group_ids: frozenset[UUID] # For feature flag targetingInfrastructure converts Django's User to UserContext, including group memberships. Application services receive UserContext for authorization decisions and feature flag targeting. The domain layer remains completely auth-unaware.
Problem: The frontend needs to know what actions the current user can perform to show/hide UI elements. Full HATEOAS (embedding action links in every response) is complex to implement and often ignored by SPAs anyway.
Solution: Role-Based Capabilities
The API returns a capabilities list with the user object at login:
{
"user": {
"id": "...",
"username": "admin",
"role": "admin",
"capabilities": ["products:create", "products:delete", "orders:view_all", ...]
}
}Capabilities are derived from the user's role in the domain layer:
class Capability(Enum):
PRODUCTS_CREATE = "products:create"
PRODUCTS_DELETE = "products:delete"
# ...
ROLE_CAPABILITIES = {
Role.ADMIN: frozenset({Capability.PRODUCTS_CREATE, Capability.PRODUCTS_DELETE, ...}),
Role.CUSTOMER: frozenset({Capability.PRODUCTS_VIEW, Capability.CART_MODIFY, ...}),
}The frontend uses a RequireCapability component for clean conditional rendering:
<RequireCapability capability={Capabilities.PRODUCTS_CREATE}>
<CreateProductButton />
</RequireCapability>Benefits:
- Simple: Single request gets all permissions upfront
- Type-safe: Frontend uses constants, not string literals
- Cacheable: Frontend can cache capabilities for the session
- Pragmatic: Avoids HATEOAS complexity while solving the same problem
Problem: Concurrent requests can corrupt data (overselling stock, duplicate records, lost updates).
Solution: Multiple Patterns Based on Scenario
| Scenario | Pattern | Implementation |
|---|---|---|
| Read-then-modify | Row-level locking | select_for_update() |
| Singleton creation | Atomic get-or-create | get_or_create() |
| Uniqueness validation | DB constraint + fallback | Unique constraint + catch IntegrityError |
Problem: Deploying new features requires code changes. Rolling back problematic features means redeploying. Rolling out features gradually to specific users is difficult without complex deployment strategies.
Solution: Feature Flag System with User Group Targeting
- Port interface:
FeatureFlagPort.is_enabled(flag_name, user_context) - Database adapter: Stores flags with optional target groups
- Admin API: CRUD operations for flags, groups, and targeting
- Fail-safe defaults: Unknown flags return
false
Targeting Logic:
def is_enabled(flag_name, user_context):
# Master kill switch - if disabled, always False
if not flag.enabled:
return False
# No target groups - enabled for everyone
if not flag.target_groups:
return True
# With target groups - check user membership
return user_context and user_in_any_target_group(user_context, flag.target_groups)User Groups vs Roles:
- Roles are for authorization (what you CAN do): Admin, Customer
- Groups are for targeting (what you SEE): beta_testers, internal_users, power_users
A customer can be in the "beta_testers" group without gaining admin privileges. This enables gradual rollouts, A/B testing, and feature gating without coupling to the authorization model.
Problem: Hard deletes lose data permanently. Deleting products breaks order history.
Solution: Soft Delete + Snapshot Pattern
- Products have
deleted_attimestamp (null = active) - Soft-deleted products hidden from catalog but remain in database
- Order items store product name and price as snapshots
- Admins can view and restore soft-deleted products
Problem: Use cases live in developers' heads or scattered documentation. No single source of truth connects requirements, tests, and domain language. New team members struggle to understand what the system does.
Solution: Behavior Driven Development (BDD) with pytest-bdd
Feature files document use cases in Gherkin syntax using the project's ubiquitous language:
Feature: Add Item to Cart
As a Customer
I want to add products to my cart
So that I can purchase them later
Scenario: Successfully add item to cart
Given I am logged in as a Customer
And a product "Laptop" exists with price "$999.99" and stock quantity 10
When I add 2 "Laptop" to my cart
Then my cart should contain 2 "Laptop" at "$999.99" each
And the product "Laptop" should have stock quantity 8Benefits:
- Living documentation - Feature files are both requirements and executable tests
- Ubiquitous language - Scenarios use domain terms from GLOSSARY.md
- Use case traceability - Each scenario maps to a specific user action
- Onboarding aid - New developers understand behavior by reading features
See BDD-APPROACH.md for full details.
Problem: External service integrations (payment processors, address verification, shipping APIs) tightly couple infrastructure code to business logic, making testing difficult and creating vendor lock-in.
Solution: Port/Adapter Pattern for External Services
Define a port interface in the application layer:
class AddressVerificationPort(ABC):
@abstractmethod
def verify(self, street, city, state, postal_code, country) -> AddressVerificationResult:
passImplement adapters in infrastructure:
class StubAddressVerificationAdapter(AddressVerificationPort):
"""Stub for development/testing."""
def verify(self, ...) -> AddressVerificationResult:
# Returns standardized address or validation errors
class USPSAddressVerificationAdapter(AddressVerificationPort):
"""Real adapter using USPS API."""
def verify(self, ...) -> AddressVerificationResult:
# HTTP client calls to USPS serviceBenefits:
- Testability: Use stub adapter in tests without network calls
- Flexibility: Swap providers (USPS → SmartyStreets) by changing adapter
- Fail-closed: Reject operations if external service unavailable
- Separation: Business logic doesn't know about HTTP, retries, or API keys
Problem: Production applications need visibility into health, performance, and behavior. Without structured logging, request correlation, and metrics, debugging issues across distributed operations becomes extremely difficult.
Solution: Layered Observability with Request Correlation
| Component | Purpose | Endpoint/Location |
|---|---|---|
| Health Check | Liveness/readiness probes | GET /api/health/ |
| Metrics | Prometheus-format counters, gauges, histograms | GET /api/metrics/ |
| Structured Logging | JSON logs with consistent fields | JSONFormatter |
| Request Correlation | Track requests across all operations | X-Request-ID header |
Request ID Correlation: Every request gets a unique ID (from X-Request-ID header or auto-generated). This ID flows through:
- All log entries via
request_idfield - Domain event dispatch and audit logs
- Response header for client-side correlation
# Request context using Python's contextvars (thread-safe)
_request_id: ContextVar[Optional[str]] = ContextVar("request_id", default=None)
# Middleware sets it at request start
request_id = request.META.get("HTTP_X_REQUEST_ID") or generate_request_id()
set_request_id(request_id)
# Available anywhere in the request lifecycle
logger.info("Processing order", extra={"request_id": get_request_id()})Cross-Aggregate Operation Timing: Application services instrument multi-aggregate operations:
def add_item(self, product_id: str, quantity: int, user_context: UserContext) -> Cart:
with time_operation("cart_add_item"):
# Coordinates Cart and Product aggregates
# Duration recorded to metrics as histogramBenefits:
- Debuggability: Trace any request through logs, events, and metrics
- Production-ready: Health checks for container orchestration
- Standards-based: Prometheus metrics format for monitoring tools
- DDD-aware: Timing specifically targets cross-aggregate operations
Problem: Software architectures erode over time. Developers (or AI assistants) inadvertently add imports that violate layer boundaries—domain importing from infrastructure, aggregates importing from each other—creating tight coupling that undermines the architecture's benefits.
Solution: Automated Import Linting with import-linter
Define architectural contracts in .importlinter:
[importlinter:contract:domain-layer-independence]
name = Domain layer must not import from application or infrastructure
type = forbidden
source_modules = domain
forbidden_modules = application, infrastructure, django
[importlinter:contract:cart-aggregate-isolation]
name = Cart aggregate must not import from other aggregates
type = forbidden
source_modules = domain.aggregates.cart
forbidden_modules = domain.aggregates.product, domain.aggregates.orderRun lint-imports to verify:
Contracts: 5 kept, 0 broken.
Benefits:
- Automated enforcement: Violations caught immediately, not in code review
- Self-documenting: Contracts serve as architecture documentation
- CI-ready: Add to build pipeline to block violating PRs
- Granular control: Enforce both layer boundaries and aggregate isolation
project-root/
├── backend/
│ ├── domain/ # Pure business logic (NO Django)
│ │ ├── aggregates/ # Product, Cart, Order
│ │ ├── events.py # Base DomainEvent class
│ │ └── user_context.py # Auth abstraction
│ ├── application/ # Use case orchestration
│ │ ├── ports/ # Interface definitions
│ │ └── services/ # Application services
│ └── infrastructure/ # Framework-dependent code
│ ├── events/ # Event dispatcher
│ └── django_app/ # Django implementation
├── frontend/
│ └── src/
│ ├── components/ # Reusable UI components
│ ├── constants/ # Shared constants (e.g., capabilities)
│ ├── contexts/ # React contexts (e.g., AuthContext)
│ ├── pages/ # Page components
│ ├── services/ # API client
│ └── types/ # TypeScript types
└── specs/ # Architecture documentation
cd backend
python -m pytestcd frontend
npm test- Architecture - Detailed architectural patterns
- Design - API design and data entities
- Requirements - Functional requirements
- Glossary - Ubiquitous language and domain terms
- BDD Approach - Behavior Driven Development testing
- Domain Events - Event system implementation
- Authentication - Auth implementation
- Feature Flags - Feature flag system
- Soft Delete - Soft delete implementation
- Address Verification - Third-party API integration pattern
The following are intentionally deferred to keep the implementation simple:
- Value Objects: Wrapping primitives (Money, Quantity) for type safety
- API Specification: OpenAPI/Swagger for contract-first development
- Dependency Injection Framework: Currently using manual DI
- One Django App Per Aggregate: Currently using single app for simplicity
See ARCHITECTURE.md for trade-off analysis.
This project is dedicated to the public domain under CC0 1.0 Universal. You may use, copy, modify, distribute, and sell this code without restriction and without attribution.
See the LICENSE file for the full legal text.