Loading learning content...
Every software system begins as a mental model—a conceptual understanding of how the real world works. A banking application models accounts, transactions, and customers. An e-commerce platform models products, carts, and orders. A hospital management system models patients, doctors, and appointments.
The critical skill that separates junior developers from senior engineers is the ability to translate these real-world concepts into clean, well-structured code. This translation is not mechanical—it requires judgment, experience, and a deep understanding of both the domain and software design principles.
Done well, your code becomes a living model of the business domain, readable by domain experts and engineers alike. Done poorly, you create a tangled mess where the code bears no resemblance to the problem it solves.
By the end of this page, you will understand how to identify domain concepts worthy of becoming classes, recognize the characteristics of well-modeled domain entities, and apply systematic techniques to translate business requirements into object-oriented structures that are both correct and maintainable.
Before writing any code, experienced engineers immerse themselves in the problem domain. The domain is the subject area the software addresses—finance, healthcare, logistics, entertainment, or any other field.
Why domain understanding comes first:
Code is a solution to a problem. If you don't deeply understand the problem, any solution you create will be accidental at best and wrong at worst. Consider these failure modes:
The best source of domain knowledge is people who work in the domain daily. A 30-minute conversation with a loan officer will teach you more about lending workflows than a week of reading documentation. Listen for the nouns they use repeatedly—these often become your core classes.
The discovery process:
Domain discovery is iterative and never truly complete. Use these techniques:
Event Storming: List all significant events that occur in the domain ("OrderPlaced", "PaymentReceived", "ShipmentDispatched"). Events reveal the lifecycle of key entities.
Entity Mapping: Identify all "things" that persist over time and have changing state. These become your core classes.
Behavior Cataloging: List what actions can be taken and by whom. Behaviors often become methods on your domain classes.
Constraint Identification: Note all business rules and invariants ("An order cannot be shipped until payment is confirmed"). These shape your class interfaces and validation logic.
Not every domain concept deserves to be a class. The art lies in recognizing which concepts are significant enough to warrant first-class representation in code. Consider these guiding principles:
Prime candidates for classes:
Entities with Identity: If the domain assigns a unique identifier to something (OrderId, CustomerId, AccountNumber), it's almost certainly a class. Entities persist over time and their state changes.
Concepts with Behavior: If domain experts describe something that "does" things rather than just "is" something, it likely needs to be an object with methods.
Aggregates of Related Data: When multiple attributes naturally cluster together and move as a unit, they form a cohesive class.
Domain Concepts with Rules: If specific rules govern how something can change or what values are valid, encapsulating those rules in a class provides enforcement.
| Domain Concept | Class Candidate? | Reasoning |
|---|---|---|
| BankAccount | Yes (Entity) | Has identity (account number), state (balance), and behavior (deposit/withdraw) |
| Transaction | Yes (Entity) | Has identity (transaction ID), captures a moment in time, immutable history |
| CustomerAddress | Yes (Value Object) | Multiple related fields (street, city, zip), but no unique identity—interchangeable by value |
| AccountBalance | Possibly (Value Object) | Represents currency + amount, might benefit from encapsulation for precision |
| "Is Customer Active" | No (Attribute) | Simple boolean, better as a property on Customer class |
| Current Date | No (External) | Infrastructure concern, not a domain concept |
The Noun Analysis Technique:
A pragmatic starting point is analyzing the nouns in requirements documents and domain expert conversations:
"When a Customer places an Order for Products, the system creates an Invoice and schedules Delivery."
Nouns highlighted: Customer, Order, Products, Invoice, Delivery—all likely classes.
But be careful. Not all nouns are classes:
A common anti-pattern is creating classes that are just data containers with getters and setters, while all behavior lives in separate "service" classes. This is called an Anemic Domain Model. True domain objects encapsulate both state AND behavior—they know how to validate themselves and transition their own state.
One of the most important distinctions in domain modeling is between Entities and Value Objects. Getting this distinction right leads to cleaner, more maintainable code.
Entities:
Value Objects:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
# ENTITY: Has identity, mutable stateclass BankAccount: def __init__(self, account_id: str, holder_name: str): self._account_id = account_id # Identity - never changes self._holder_name = holder_name self._balance = Money.zero() # State - changes over time @property def account_id(self) -> str: return self._account_id def deposit(self, amount: "Money") -> None: """Behavior that mutates state""" self._balance = self._balance.add(amount) def __eq__(self, other) -> bool: """Entities are equal if IDs match""" if not isinstance(other, BankAccount): return False return self._account_id == other._account_id # VALUE OBJECT: No identity, immutableclass Money: def __init__(self, amount: int, currency: str): # Validation on construction if amount < 0: raise ValueError("Amount cannot be negative") self._amount = amount # In smallest unit (cents) self._currency = currency @classmethod def zero(cls) -> "Money": return cls(0, "USD") def add(self, other: "Money") -> "Money": """Returns NEW Money object - immutable""" if self._currency != other._currency: raise ValueError("Currency mismatch") return Money(self._amount + other._amount, self._currency) def __eq__(self, other) -> bool: """Value objects are equal if ALL attributes match""" if not isinstance(other, Money): return False return (self._amount == other._amount and self._currency == other._currency) def __hash__(self) -> int: """Immutable, so can be hashed for use in sets/dicts""" return hash((self._amount, self._currency))When in doubt, model a concept as a Value Object first. Value Objects are simpler (no identity management, immutable, thread-safe). Only promote to Entity when you discover a genuine need for identity tracking. Many concepts that seem like entities (like "Price" or "Rating") are actually better modeled as immutable value objects.
Domain concepts don't exist in isolation—they relate to each other in meaningful ways. Correctly modeling these relationships is crucial for a faithful domain representation.
Types of relationships to identify:
Association: Two entities know about each other. A Customer has Orders; an Order belongs to a Customer.
Aggregation: A "whole" contains "parts" that can exist independently. A Team has Members, but Members can exist without the Team.
Composition: A "whole" contains "parts" that cannot exist independently. An Order contains OrderLines; if the Order is deleted, its OrderLines have no meaning.
Dependency: One object uses another temporarily but doesn't hold a reference. A PriceCalculator uses a DiscountPolicy.
Translating relationships to code:
Relationships manifest as fields/properties in your classes. The key decisions are:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
from typing import Listfrom dataclasses import dataclass # COMPOSITION: Order owns OrderLines - they can't exist independentlyclass Order: def __init__(self, order_id: str, customer_id: str): self._id = order_id self._customer_id = customer_id # Reference by ID, not object self._lines: List[OrderLine] = [] # Order owns the lines self._status = "PENDING" def add_line(self, product_id: str, quantity: int, unit_price: int) -> None: """Order creates its own lines - composition""" line = OrderLine( order_id=self._id, product_id=product_id, quantity=quantity, unit_price=unit_price ) self._lines.append(line) def total(self) -> int: return sum(line.subtotal for line in self._lines) @dataclassclass OrderLine: """Can't exist without an Order - Composition relationship""" order_id: str product_id: str quantity: int unit_price: int @property def subtotal(self) -> int: return self.quantity * self.unit_price # AGGREGATION: Team references Members, but Members can exist independentlyclass Team: def __init__(self, team_id: str, name: str): self._id = team_id self._name = name self._member_ids: List[str] = [] # Reference IDs, not objects def add_member(self, member_id: str) -> None: """Team aggregates members - they exist independently""" if member_id not in self._member_ids: self._member_ids.append(member_id) def remove_member(self, member_id: str) -> None: """Member continues to exist after removal from team""" self._member_ids.remove(member_id) class Employee: """Exists independently of any Team""" def __init__(self, employee_id: str, name: str): self._id = employee_id self._name = name # No reference back to Team - unidirectional relationshipIn many modern architectures, entities reference each other by ID rather than direct object references. This reduces memory usage, avoids circular reference issues, and aligns with how databases work. When you need the full object, you load it through a repository. This is especially important in distributed systems where objects may live in different services.
Let's walk through a systematic process for translating a real-world scenario into classes. We'll use an example: designing a Library Management System.
Step 1: Gather Domain Requirements
"The library has Books that Members can borrow. Each Book has a title, author, and ISBN. Members have a membership ID and can borrow up to 5 books for 14 days. If a book is overdue, the member is charged a fine."
Step 2: Extract Candidate Classes
Nouns: Library, Books, Members, title, author, ISBN, membership ID, fine
Filtering:
Step 3: Identify Hidden Concepts
One of the most valuable skills is recognizing concepts that aren't explicitly stated but are crucial:
These "hidden" concepts often emerge when you ask: "What happens between X and Y?"
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
from datetime import datetime, timedeltafrom dataclasses import dataclassfrom typing import Optional # Value Object: Structured identifier with validation@dataclass(frozen=True) # frozen=True makes it immutableclass ISBN: value: str def __post_init__(self): if not self._is_valid_isbn(self.value): raise ValueError(f"Invalid ISBN: {self.value}") def _is_valid_isbn(self, isbn: str) -> bool: # Simplified ISBN-13 validation cleaned = isbn.replace("-", "") return len(cleaned) == 13 and cleaned.isdigit() # Value Object: Money for fines@dataclass(frozen=True)class Money: 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) # Entity: Bookclass Book: def __init__(self, isbn: ISBN, title: str, author: str): self._isbn = isbn # Unique identifier self._title = title self._author = author @property def isbn(self) -> ISBN: return self._isbn @property def title(self) -> str: return self._title # Entity: Memberclass Member: MAX_LOANS = 5 def __init__(self, member_id: str, name: str): self._id = member_id self._name = name self._active_loans: list["Loan"] = [] @property def member_id(self) -> str: return self._id @property def can_borrow(self) -> bool: return len(self._active_loans) < self.MAX_LOANS def borrow(self, book: Book) -> "Loan": """Domain behavior with business rules""" if not self.can_borrow: raise DomainException("Member has reached borrowing limit") loan = Loan.create(member_id=self._id, isbn=book.isbn) self._active_loans.append(loan) return loan def return_book(self, isbn: ISBN) -> Optional[Money]: """Returns a fine if overdue, None otherwise""" loan = next((l for l in self._active_loans if l.isbn == isbn), None) if not loan: raise DomainException("No active loan for this book") fine = loan.calculate_fine() self._active_loans.remove(loan) return fine # Entity: Loan - The hidden but crucial domain conceptclass Loan: LOAN_DURATION_DAYS = 14 FINE_PER_DAY = Money(50, "USD") # $0.50 per day def __init__(self, loan_id: str, member_id: str, isbn: ISBN, borrow_date: datetime, due_date: datetime): self._id = loan_id self._member_id = member_id self._isbn = isbn self._borrow_date = borrow_date self._due_date = due_date @classmethod def create(cls, member_id: str, isbn: ISBN) -> "Loan": now = datetime.now() return cls( loan_id=generate_id(), # Infrastructure concern member_id=member_id, isbn=isbn, borrow_date=now, due_date=now + timedelta(days=cls.LOAN_DURATION_DAYS) ) @property def isbn(self) -> ISBN: return self._isbn @property def is_overdue(self) -> bool: return datetime.now() > self._due_date def calculate_fine(self) -> Optional[Money]: if not self.is_overdue: return None days_overdue = (datetime.now() - self._due_date).days return Money(self.FINE_PER_DAY.amount_cents * days_overdue) class DomainException(Exception): """Exception for business rule violations""" pass def generate_id() -> str: import uuid return str(uuid.uuid4())Notice how the business rules live IN the domain objects. Member knows its borrowing limit and enforces it. Loan knows how to calculate fines. This is the essence of rich domain modeling—behavior and data together, not separated into "dumb" data classes and "smart" service classes.
Even experienced developers make systematic errors when translating domains to code. Recognizing these patterns helps you avoid them.
email: str loses the validation a class EmailAddress provides.calculate_order_total(order), the method should probably be order.total().Customer class in billing, shipping, and marketing. Each context may need a different view of the customer.The CRUD Trap:
A particularly seductive mistake is designing around Create/Read/Update/Delete operations rather than domain behaviors. Consider:
updateOrderStatus(orderId, newStatus)order.confirm(), order.ship(), order.cancel(reason)The CRUD approach treats the order as passive data being manipulated from outside. The domain approach gives the order agency—it knows how to transition itself and can enforce rules ("Can't cancel a shipped order").
The difference compounds: CRUD leads to scattered validation and business rules throughout service classes. Domain modeling concentrates rules where they're enforced consistently.
Don't let your ORM or framework dictate your domain model. It's tempting to add @Entity annotations to classes and let the framework generate everything. But this couples your domain to infrastructure. The domain model should be pure—no framework dependencies. Mapping to/from persistence is a separate concern.
Translating domain concepts to classes is a skill that develops with practice. Let's consolidate the key principles:
What's next:
Now that we can identify and create classes from domain concepts, the next page explores Ubiquitous Language—the practice of using consistent domain terminology throughout code, conversations, and documentation. This ensures your code reads like domain documentation and domain experts can participate in technical discussions.
You now understand how to systematically translate real-world domain concepts into well-structured classes. The key insight: your code should model the domain, not the database, the UI, or the framework. When domain experts can read your class names and understand what they represent, you've succeeded.