Loading content...
Picture a team designing an e-commerce system. They start with the database schema because "we'll need to store data anyway." The schema has a products table with a status column using integers (0, 1, 2) for efficiency. The developers create a Product class that mirrors this table directly—fields matching columns, an int status field, and setter methods for every column.
Months later, the code is a maze. No one remembers what status == 2 means. Business logic is scattered across service classes because the Product object is just a data container. Adding a new product state requires database migrations, code changes in a dozen places, and prayer.
This is implementation-driven design—letting technical details (database structure, framework requirements, performance optimizations) shape your domain model instead of letting the domain drive everything else.
By the end of this page, you will recognize when implementation concerns are corrupting your domain model, understand how to separate domain design from infrastructure decisions, and learn techniques to create pure domain models that remain stable even as implementation details change.
Implementation-driven design occurs when how you'll build something determines what you build. Instead of modeling the domain accurately and then figuring out persistence, APIs, and UI, developers let these concerns shape the domain model from the start.
The correct flow:
Implementation-driven (backwards):
| Aspect | Domain-Driven | Implementation-Driven |
|---|---|---|
| Starting Point | Business requirements and domain concepts | Database schema, API spec, or UI mockups |
| Class Design | Classes model business entities with behavior | Classes mirror DB tables or DTOs |
| Property Types | Rich domain types (Money, OrderStatus) | Primitive types (int, string) |
| Method Design | Business operations (order.ship()) | Generic mutations (setStatus(2)) |
| Business Rules | Encapsulated in domain objects | Scattered across service layers |
| Change Impact | Domain changes are localized | Changes ripple through all layers |
Object-Relational Mappers (ORMs) seduce developers into implementation-driven thinking. When your framework generates classes from database tables or requires annotations on domain classes, the database is driving your design. The domain model becomes coupled to persistence technology, making both harder to change.
These anti-patterns appear frequently in codebases. Learning to recognize them is the first step to avoiding them.
id property because the database needs primary keys. Even Value Objects that shouldn't have identity get IDs.customerId integers instead of proper domain associations because that's how the schema works.VARCHAR(100) in the schema and string in code when a rich type (EmailAddress, PhoneNumber) would be more expressive and safer.status = 2 instead of OrderStatus.SHIPPED because integers are smaller in the database.@Entity, @Column, @JsonProperty annotations, coupling domain to infrastructure.displayName, formattedPrice).The most pervasive implementation-driven pattern is creating classes that exactly mirror database tables. This seems reasonable—you need to store data, so start with the schema. But it leads to systematic problems.
Example: A table-shaped Order
123456789101112131415161718192021222324252627282930313233343536373839404142
# ❌ IMPLEMENTATION-DRIVEN: Class mirrors database table class Order: """Directly mirrors 'orders' table. No domain logic.""" def __init__(self): # Default constructor for ORM hydration self.id: int = 0 self.customer_id: int = 0 self.status: int = 0 # Magic numbers! self.total_cents: int = 0 self.shipping_address_line1: str = "" self.shipping_address_line2: str = "" self.shipping_city: str = "" self.shipping_zip: str = "" self.created_at: datetime = None self.updated_at: datetime = None # Just getters and setters — anemic model def get_status(self) -> int: return self.status def set_status(self, status: int) -> None: # No validation, no business rules self.status = status # ... more getters and setters # Business logic forced into a "service"class OrderService: """Logic that should be in Order is here instead.""" def ship_order(self, order: Order) -> None: if order.status != 1: # What does 1 mean?! raise Exception("Cannot ship") # Validation scattered here, not in domain if not order.shipping_address_line1: raise Exception("No address") order.set_status(2) # And what's 2? # Hope nobody forgets to call this service and just sets status directlyProblems with this approach:
set_status(99) to an invalid valuestatus = 2 tells us nothing about business meaningAddress object123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
# ✅ DOMAIN-DRIVEN: Class models the business concept from enum import Enumfrom dataclasses import dataclassfrom typing import List class OrderStatus(Enum): """Self-documenting states with business meaning.""" PENDING = "pending" CONFIRMED = "confirmed" SHIPPED = "shipped" DELIVERED = "delivered" CANCELLED = "cancelled" @dataclass(frozen=True)class Address: """Value Object: Grouped, validated, immutable.""" line1: str line2: str city: str postal_code: str country: str def __post_init__(self): if not self.line1 or not self.city or not self.postal_code: raise ValueError("Address requires line1, city, and postal_code") @dataclass(frozen=True)class Money: """Value Object: Prevents floating point errors, handles currency.""" amount_cents: int currency: str = "USD" def add(self, other: "Money") -> "Money": if self.currency != other.currency: raise ValueError("Cannot add different currencies") return Money(self.amount_cents + other.amount_cents, self.currency) class Order: """ Rich domain model: Encapsulates data AND behavior. Models the business concept, not the database. """ def __init__( self, order_id: str, customer_id: str, shipping_address: Address, items: List["OrderItem"] ): if not items: raise ValueError("Order must have at least one item") self._id = order_id self._customer_id = customer_id self._shipping_address = shipping_address self._items = list(items) # Defensive copy self._status = OrderStatus.PENDING @property def order_id(self) -> str: return self._id @property def status(self) -> OrderStatus: return self._status @property def total(self) -> Money: """Calculate total from items — derived, not stored.""" total = Money(0) for item in self._items: total = total.add(item.subtotal) return total def confirm(self) -> None: """ Customer confirms the order. Business rule: Can only confirm pending orders. """ if self._status != OrderStatus.PENDING: raise OrderException( f"Cannot confirm order in {self._status.value} status" ) self._status = OrderStatus.CONFIRMED def ship(self, tracking_number: str) -> None: """ Order is shipped. Business rule: Can only ship confirmed orders. """ if self._status != OrderStatus.CONFIRMED: raise OrderException( f"Cannot ship order in {self._status.value} status" ) if not self._shipping_address: raise OrderException("Cannot ship without address") self._status = OrderStatus.SHIPPED self._tracking_number = tracking_number def cancel(self, reason: str) -> None: """ Cancel the order. Business rule: Cannot cancel shipped or delivered orders. """ if self._status in (OrderStatus.SHIPPED, OrderStatus.DELIVERED): raise OrderException( f"Cannot cancel {self._status.value} order" ) self._status = OrderStatus.CANCELLED self._cancellation_reason = reason class OrderException(Exception): """Domain-specific exception for order rule violations.""" pass @dataclassclass OrderItem: product_id: str quantity: int unit_price: Money @property def subtotal(self) -> Money: return Money(self.unit_price.amount_cents * self.quantity)Notice that the domain-driven Order has no database annotations, no auto-generated ID, no default constructor for ORM. It's pure domain logic. How it gets persisted is a SEPARATE concern handled by a repository layer. This separation keeps the domain model clean and testable.
The solution to implementation-driven design is deliberate separation of concerns. Your domain model should be pure—containing only business logic, free of infrastructure dependencies.
The layered architecture:
The key insight: Infrastructure adapts to the domain, never the reverse.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
# Domain Layer: Pure business objects, no infrastructure class Order: """Pure domain entity. Knows nothing about persistence.""" def __init__(self, order_id: str, customer_id: str, items: list): self._id = order_id self._customer_id = customer_id self._items = items self._status = OrderStatus.PENDING def confirm(self) -> None: # Pure business logic if self._status != OrderStatus.PENDING: raise OrderException("Cannot confirm") self._status = OrderStatus.CONFIRMED # Domain Layer: Repository interface (abstract, no implementation)from abc import ABC, abstractmethod class OrderRepository(ABC): """ Abstract interface in domain layer. Defines WHAT the domain needs, not HOW it's implemented. """ @abstractmethod def find_by_id(self, order_id: str) -> Order | None: """Retrieve order by ID. Returns None if not found.""" pass @abstractmethod def save(self, order: Order) -> None: """Persist order changes.""" pass @abstractmethod def find_pending_orders(self) -> list[Order]: """Find all orders awaiting processing.""" pass # Infrastructure Layer: Concrete implementation class PostgresOrderRepository(OrderRepository): """ Infrastructure implementation of the domain interface. This class knows about databases; the domain does not. """ def __init__(self, connection): self._conn = connection def find_by_id(self, order_id: str) -> Order | None: # SQL is here, not in domain row = self._conn.execute( "SELECT * FROM orders WHERE id = %s", (order_id,) ).fetchone() if not row: return None # Translate from database row to domain object return self._hydrate_order(row) def save(self, order: Order) -> None: # Translate from domain object to database row self._conn.execute( """ INSERT INTO orders (id, customer_id, status, ...) VALUES (%s, %s, %s, ...) ON CONFLICT (id) DO UPDATE SET status = %s, ... """, ( order.order_id, order._customer_id, order.status.value, # Store enum as string ... ) ) def _hydrate_order(self, row) -> Order: """Convert database row to domain object.""" # Handle the translation here, including: # - Converting status string to enum # - Loading related items # - Converting address columns to Address object items = self._load_items(row["id"]) address = Address( line1=row["shipping_line1"], line2=row["shipping_line2"], city=row["shipping_city"], postal_code=row["shipping_zip"], country=row["shipping_country"] ) order = Order.__new__(Order) # Bypass __init__ for hydration order._id = row["id"] order._customer_id = row["customer_id"] order._status = OrderStatus(row["status"]) order._shipping_address = address order._items = items return order # Application Layer: Use case that coordinates domain and infrastructure class ConfirmOrderUseCase: """ Application service orchestrating the use case. Coordinates between domain and infrastructure. """ def __init__(self, order_repo: OrderRepository, email_service): self._order_repo = order_repo self._email_service = email_service def execute(self, order_id: str) -> None: # Load domain object via repository order = self._order_repo.find_by_id(order_id) if not order: raise NotFoundError(f"Order {order_id} not found") # Execute domain logic order.confirm() # Pure business logic in the domain # Persist changes via repository self._order_repo.save(order) # Side effects via infrastructure services self._email_service.send_confirmation(order)Notice the dependency direction: Infrastructure depends on Domain, not vice versa. The domain layer defines the OrderRepository interface; the infrastructure layer provides PostgresOrderRepository. The domain never imports anything from infrastructure. This makes the domain completely testable without databases.
Frameworks often demand things that conflict with good domain design: default constructors, public setters, annotations, mutable collections. Here's how to accommodate frameworks without corrupting your domain model.
Strategy 1: Separate Domain Models from Persistence Models
Maintain two class hierarchies:
Translate between them in the repository layer.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
# Domain Model: Pure, no framework pollution class Customer: """Domain entity with encapsulated state and behavior.""" def __init__(self, customer_id: str, email: EmailAddress, name: str): self._id = customer_id self._email = email # Value Object self._name = name self._status = CustomerStatus.ACTIVE def suspend(self, reason: str) -> None: if self._status == CustomerStatus.SUSPENDED: raise CustomerException("Already suspended") self._status = CustomerStatus.SUSPENDED self._suspension_reason = reason @property def email(self) -> EmailAddress: return self._email # Persistence Model: Framework-specific, annotation-heavy from sqlalchemy import Column, String, Enum as SQLEnumfrom sqlalchemy.ext.declarative import declarative_base Base = declarative_base() class CustomerRecord(Base): """ Persistence model for SQLAlchemy. This class is NOT used in business logic—only for storage. """ __tablename__ = "customers" id = Column(String, primary_key=True) email = Column(String, nullable=False) # Stored as string name = Column(String, nullable=False) status = Column(String, nullable=False) suspension_reason = Column(String, nullable=True) # Framework requirements satisfied here def __init__(self): pass # Empty constructor for ORM # Repository translates between domain and persistence class SQLAlchemyCustomerRepository(CustomerRepository): def find_by_id(self, customer_id: str) -> Customer | None: record = self._session.query(CustomerRecord).get(customer_id) if not record: return None return self._to_domain(record) def save(self, customer: Customer) -> None: record = self._to_record(customer) self._session.merge(record) self._session.flush() def _to_domain(self, record: CustomerRecord) -> Customer: """Convert persistence model to domain model.""" customer = Customer.__new__(Customer) customer._id = record.id customer._email = EmailAddress(record.email) # Wrap in Value Object customer._name = record.name customer._status = CustomerStatus(record.status) customer._suspension_reason = record.suspension_reason return customer def _to_record(self, customer: Customer) -> CustomerRecord: """Convert domain model to persistence model.""" record = CustomerRecord() record.id = customer._id record.email = customer._email.value # Unwrap Value Object record.name = customer._name record.status = customer._status.value record.suspension_reason = getattr(customer, '_suspension_reason', None) return recordStrategy 2: Isolated Annotations
If maintaining two class hierarchies feels like too much overhead, minimize framework intrusion:
Strategy 3: Domain-Preserving ORM Configuration
Some ORMs support:
Invest time learning these features to reduce framework intrusion.
Complete separation has a cost: more code, more mapping. For smaller projects, a pragmatic approach is acceptable—minimally-invasive annotations that don't change domain behavior. The goal is preventing implementation details from DISTORTING the domain, not eliminating every framework reference at all costs.
If you've inherited a codebase with implementation-driven models, systematic refactoring can recover a clean domain. Here are the warning signs and remediation patterns.
status = 2 instead of OrderStatus.SHIPPEDString email instead of EmailAddressUsersTable, users_row, OrdersRecord| Problem | Refactoring | Benefit |
|---|---|---|
| Magic numbers | Replace with Enum type | Self-documenting, compiler-checked |
| Primitives for domain concepts | Extract to Value Object | Validation, expressiveness, reuse |
| Setters for all fields | Remove setters; add domain methods | Encapsulation, invariant protection |
| Default constructor | Replace with factory or required-arg constructor | Objects always valid |
| Logic in services | Move to entity methods | Cohesion, discoverability |
| ORM annotations on domain | Extract persistence model | Separation of concerns |
| Database-shaped relationships | Model as domain associations | Clarity, proper navigation |
Incremental refactoring strategy:
Start with Value Objects: Introduce small, immutable types (Money, EmailAddress). Low risk, immediate benefit.
Introduce Enums: Replace magic numbers with meaningful enums. Can be done without changing structure.
Add domain methods one at a time: For each behavior in a service, move it to the entity. Keep the service method as a thin wrapper initially.
Protect invariants: Add validation in constructors and methods. Remove dangerous setters.
Introduce Repository pattern: Abstract persistence behind interfaces. This enables the final step.
Separate persistence models: Create dedicated classes for database mapping, translate in repositories.
Don't try to fix everything at once. Each refactoring step should be small, tested, and merged. Over weeks and months, the codebase transforms. The key is consistent direction—every change moves toward a cleaner domain model, never away from it.
Implementation-driven design is seductive because it feels efficient—start with the database, let the framework generate classes, ship fast. But the technical debt compounds. Domain-first design requires more upfront thought but creates systems that remain maintainable as they grow.
order.ship() belong in Order, not in OrderService.What's next:
With pure domain models that accurately reflect business concepts, we need to ensure they actually meet requirements. The final page covers validating design against requirements—techniques for checking that your model captures all necessary functionality before committing to implementation.
You now understand how implementation details can corrupt domain models and how to maintain separation of concerns. The discipline of domain-first thinking creates code that mirrors business reality—readable, maintainable, and adaptable to changing requirements.