Skip to main content

What is Domain Modeling? — From Requirements to Objects

Introduction

A product manager walks into your office and says: "I need an online store. Customers browse products, add them to a cart, checkout, and track their deliveries. Oh, and we need inventory management, discount codes, and customer reviews."

You nod, open your IDE, and immediately start writing a StoreApplication class with dozens of methods — addProduct(), checkout(), applyDiscount(), trackShipment(). Six months later the codebase is an unnavigable tangle. Every new feature breaks three old ones. Nobody can explain where "order logic" lives because it's smeared across twelve files.

The missing step? Domain modeling — the disciplined practice of translating real-world requirements into a structured map of objects, relationships, and behaviors before you write a single line of production code.

Domain modeling is the bridge between what the stakeholder describes in plain language and what you express in classes and interfaces. It is the single most important skill that separates engineers who build maintainable systems from those who build ones that collapse under their own weight.

In this tutorial, you will learn what a domain model is, why every serious design starts with one, and how it connects to the OOP concepts and UML diagrams you covered in Sections 3 through 5. We'll use an e-commerce system as our running example — the same domain we'll model progressively through every tutorial in this section.

What is a Domain Model?

A domain is the specific area of knowledge or activity your software addresses. For an e-commerce system, the domain includes products, orders, payments, shipping, and customer accounts. For a hospital system, the domain includes patients, doctors, appointments, prescriptions, and billing.

A domain model is a conceptual representation of the key entities, attributes, behaviors, and relationships within that domain. It is not code. It is not a database schema. It is a shared vocabulary between developers and stakeholders that captures what the system is about.

Think of it like an architect's blueprint for a building. The blueprint doesn't specify which brand of nails to use or how to mix the concrete — those are implementation details. It specifies where the walls go, which rooms connect to which, and how people flow through the space. A domain model does the same for software.

The Three Pillars of a Domain Model

  1. Entities — Objects with identity that persist over time (a Customer, an Order, a Product). In the next tutorial, we'll distinguish these from Value Objects — a fundamental classification.
  2. Relationships — How entities connect (a Customer places an Order, an Order contains OrderItems). These map to the association, aggregation, and composition relationships from S4.
  3. Behaviors — What entities can do (an Order can be cancelled, a Cart can calculate its total). These become the methods on your classes.

These three pillars map directly to the OOP concepts you already know: classes and objects (S3), relationships like association, composition, and aggregation (S4), and the UML diagrams that visualize structure and behavior (S5).

Why Domain Modeling Matters

1. It Prevents the "God Class" Anti-Pattern

Without a domain model, developers lump everything into one or two massive classes. A StoreManager that handles users, inventory, orders, payments, and shipping — violating every principle from S6 and S7. Domain modeling forces you to identify separate concepts and give each its own class, naturally applying the Single Responsibility Principle.

2. It Creates a Shared Language (Ubiquitous Language)

When developers say "order" but the business team means "purchase request," misunderstandings cause bugs. A domain model establishes a ubiquitous language — a set of terms that everyone agrees on. Eric Evans coined this term in Domain-Driven Design, and it remains one of the most powerful tools for reducing communication errors.

For example, in our e-commerce domain: is a "shopping cart" the same as an "order"? No — a cart is a temporary, mutable collection of items that becomes an immutable order only when the customer checks out. Without a domain model, developers might use these terms interchangeably, creating subtle state management bugs.

3. It Makes UML Diagrams Meaningful

A class diagram drawn without a domain model is just boxes and arrows. A class diagram derived from a domain model is a precise map of the problem space. Every class has a reason to exist, every relationship has a real-world justification, and every method reflects an actual business operation.

4. It Guides Design Pattern Decisions

When you understand your domain deeply, pattern choices become natural. An Order that transitions through states (Placed → Paid → Shipped → Delivered → Cancelled) naturally calls for the State Pattern (S12). A Cart that applies different discount strategies naturally calls for the Strategy Pattern (S12). A notification system where order events trigger emails, SMS, and dashboard updates naturally calls for the Observer Pattern (S12). Domain understanding drives design — not the other way around.

5. It Catches Requirements Gaps Early

Building a domain model forces you to ask hard questions: "Can a customer have multiple shipping addresses?" "What happens to cart items if a product goes out of stock?" "Can a single order span multiple sellers?" These questions are cheap to answer during modeling — and catastrophically expensive to discover during implementation.

Real-World Analogy — The City Planner

Imagine you're a city planner designing a new neighborhood. Before laying a single pipe or pouring any concrete, you need a master plan:

  • What are the key structures? Houses, schools, parks, shops, roads — these are your entities
  • How do they connect? Roads link houses to shops, sidewalks connect to parks — these are your relationships
  • What rules must hold? Every house must be within 500 meters of a school. No shop can be inside a residential zone. Fire trucks must reach every house within 5 minutes — these are your domain invariants (we'll formalize them in Tutorial 4)
  • Who interacts with what? Residents use shops, children attend schools, maintenance crews service parks — these are your behaviors

Without this master plan, you might build a beautiful school with no road access, or a park surrounded by factories. The individual buildings "work" — they have walls and roofs — but the interactions are broken.

Domain modeling is your master plan for software. It ensures every class exists for a reason, every relationship serves a purpose, and every behavior reflects genuine business needs.

From Requirements to Domain Model — The Process

Building a domain model from requirements follows a repeatable five-step sequence. Let's walk through it using our e-commerce system.

Step 1: Gather Requirements

Collect functional requirements from stakeholders. Write them down as clear, specific statements:

  • "Customers can browse a product catalog and search by category"
  • "Customers can add products to a shopping cart and adjust quantities"
  • "Customers can place an order by checking out their cart with a shipping address and payment method"
  • "Each order tracks its status: placed, paid, shipped, delivered, or cancelled"
  • "The system tracks inventory — a product cannot be ordered if out of stock"
  • "Customers can view their order history and track delivery status"

Step 2: Extract Nouns → Candidate Classes

Highlight every noun in the requirements. Each noun is a candidate class — some will become standalone classes, others will turn out to be attributes:

Strong candidates (likely classes): Customer, Product, ShoppingCart, Order, Category, ShippingAddress, PaymentMethod

Weak candidates (may be attributes): quantity (attribute of a cart item), status (attribute of Order), inventory (attribute of Product or a separate service)

Step 3: Extract Verbs → Candidate Methods

Highlight every verb. These become candidate methods on the most relevant class:

  • browse, search → ProductCatalog.search(query)
  • add → ShoppingCart.addItem(product, quantity)
  • place, checkout → Customer.placeOrder() or ShoppingCart.checkout()
  • track → Order.getStatus()
  • cancel → Order.cancel()

Step 4: Identify Relationships

Determine how candidate classes connect using the relationship types from S4:

  • A Customer places many Orders → one-to-many association
  • An Order contains multiple OrderItems → composition (items cannot exist outside the order)
  • A ShoppingCart holds CartItems → composition (cart items belong to the cart)
  • A Product belongs to a Category → many-to-one association
  • An Order has a ShippingAddress and PaymentMethod → composition or value objects

Step 5: Validate with Stakeholders

Walk through the model with the business team. Ask probing questions:

  • "When a customer places an order, does the inventory automatically decrease?" → Yes
  • "Can a customer change the shipping address after placing an order?" → Only before shipping
  • "Can an order be partially cancelled (some items but not others)?" → No, entire order only
  • "Do discount codes apply per-item or per-order?" → Per-order

Every answer refines the model. We'll practice noun-verb extraction in depth in Tutorial 5.

E-Commerce Domain Model

Domain model diagram for an e-commerce system showing Customer, Product, ShoppingCart, Order, OrderItem, Category, ShippingAddress, and PaymentMethod with their relationships
Domain model diagram for an e-commerce system showing Customer, Product, ShoppingCart, Order, OrderItem, Category, ShippingAddress, and PaymentMethod with their relationships

Visualization

Domain Modeling — From Requirements to Objects

Code Example — From Model to Classes

Let's translate a core part of our e-commerce domain model into code. We'll implement Customer, Order, OrderItem, and a Money value object — demonstrating how the domain model directly guides class design.

Notice how every class, attribute, and method traces back to the domain model. Nothing is invented on the spot.

from datetime import datetime
from enum import Enum
from typing import List


class OrderStatus(Enum):
    """Lifecycle states of an order — derived from domain analysis."""
    PLACED = "PLACED"
    PAID = "PAID"
    SHIPPED = "SHIPPED"
    DELIVERED = "DELIVERED"
    CANCELLED = "CANCELLED"


class Money:
    """Value Object — identified by amount+currency, not by an ID.
    Two Money(100, 'USD') instances are equal because they represent
    the same value. We'll explore Value Objects deeply in Tutorial 2."""

    def __init__(self, amount: float, currency: str = "USD"):
        if amount < 0:
            raise ValueError("Money amount cannot be negative")
        self._amount = round(amount, 2)
        self._currency = currency

    @property
    def amount(self) -> float:
        return self._amount

    @property
    def currency(self) -> str:
        return self._currency

    def add(self, other: "Money") -> "Money":
        if self._currency != other._currency:
            raise ValueError("Cannot add different currencies")
        return Money(self._amount + other._amount, self._currency)

    def multiply(self, factor: int) -> "Money":
        return Money(self._amount * factor, self._currency)

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Money):
            return False
        return self._amount == other._amount and self._currency == other._currency

    def __repr__(self) -> str:
        return f"{self._currency} {self._amount:.2f}"


class Product:
    """Entity — identified by product_id. Tracks its own inventory."""

    def __init__(self, product_id: str, name: str, price: Money, stock_count: int):
        self._product_id = product_id
        self._name = name
        self._price = price
        self._stock_count = stock_count

    @property
    def product_id(self) -> str:
        return self._product_id

    @property
    def name(self) -> str:
        return self._name

    @property
    def price(self) -> Money:
        return self._price

    def is_in_stock(self, quantity: int = 1) -> bool:
        return self._stock_count >= quantity

    def reduce_stock(self, quantity: int) -> None:
        if quantity > self._stock_count:
            raise ValueError(f"Insufficient stock for '{self._name}'")
        self._stock_count -= quantity


class OrderItem:
    """Belongs to an Order aggregate — cannot exist independently.
    Captures the price at time of purchase so later price changes
    don't retroactively alter completed orders."""

    def __init__(self, product: Product, quantity: int):
        if quantity <= 0:
            raise ValueError("Quantity must be positive")
        self._product = product
        self._quantity = quantity
        self._price_at_purchase = product.price  # snapshot — immutable

    @property
    def product_name(self) -> str:
        return self._product.name

    @property
    def quantity(self) -> int:
        return self._quantity

    def subtotal(self) -> Money:
        return self._price_at_purchase.multiply(self._quantity)


class Order:
    """Entity and Aggregate Root — owns and controls OrderItems.
    External code accesses items ONLY through Order's methods,
    never directly. This protects domain invariants (Tutorial 4)."""

    def __init__(self, order_id: str, customer_id: str):
        self._order_id = order_id
        self._customer_id = customer_id
        self._items: List[OrderItem] = []
        self._status = OrderStatus.PLACED
        self._placed_at = datetime.now()

    @property
    def order_id(self) -> str:
        return self._order_id

    @property
    def status(self) -> OrderStatus:
        return self._status

    def add_item(self, product: Product, quantity: int) -> None:
        if not product.is_in_stock(quantity):
            raise ValueError(f"'{product.name}' — insufficient stock")
        self._items.append(OrderItem(product, quantity))
        product.reduce_stock(quantity)

    def calculate_total(self) -> Money:
        total = Money(0)
        for item in self._items:
            total = total.add(item.subtotal())
        return total

    def cancel(self) -> None:
        if self._status in (OrderStatus.SHIPPED, OrderStatus.DELIVERED):
            raise ValueError("Cannot cancel — order already shipped")
        self._status = OrderStatus.CANCELLED

    @property
    def item_count(self) -> int:
        return len(self._items)


class Customer:
    """Entity — identified by customer_id. Places and tracks orders."""

    def __init__(self, customer_id: str, name: str, email: str):
        self._customer_id = customer_id
        self._name = name
        self._email = email
        self._orders: List[Order] = []

    def place_order(self, order: Order) -> None:
        self._orders.append(order)

    def order_history(self) -> List[Order]:
        return list(self._orders)  # defensive copy


# --- Usage: the domain model in action ---
laptop = Product("PROD-001", "Laptop Pro 15", Money(1299.99), stock_count=10)
charger = Product("PROD-002", "USB-C Charger", Money(29.99), stock_count=50)

customer = Customer("CUST-001", "Alice", "alice@example.com")

order = Order("ORD-001", "CUST-001")
order.add_item(laptop, quantity=1)
order.add_item(charger, quantity=2)
customer.place_order(order)

print(f"Order {order.order_id}: {order.item_count} items")
print(f"Total: {order.calculate_total()}")   # USD 1359.97
print(f"Status: {order.status.value}")        # PLACED

order.cancel()
print(f"Status: {order.status.value}")        # CANCELLED
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;

enum OrderStatus {
    PLACED, PAID, SHIPPED, DELIVERED, CANCELLED
}

/** Value Object — equality based on amount + currency, not identity. */
class Money {
    private final double amount;
    private final String currency;

    public Money(double amount, String currency) {
        if (amount < 0) throw new IllegalArgumentException("Amount cannot be negative");
        this.amount = Math.round(amount * 100.0) / 100.0;
        this.currency = currency;
    }

    public Money(double amount) { this(amount, "USD"); }

    public double getAmount() { return amount; }
    public String getCurrency() { return currency; }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency))
            throw new IllegalArgumentException("Cannot add different currencies");
        return new Money(this.amount + other.amount, this.currency);
    }

    public Money multiply(int factor) {
        return new Money(this.amount * factor, this.currency);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money)) return false;
        Money m = (Money) o;
        return Double.compare(m.amount, amount) == 0 && currency.equals(m.currency);
    }

    @Override
    public int hashCode() { return Objects.hash(amount, currency); }

    @Override
    public String toString() { return String.format("%s %.2f", currency, amount); }
}

/** Entity — identified by productId. */
class Product {
    private final String productId;
    private final String name;
    private final Money price;
    private int stockCount;

    public Product(String productId, String name, Money price, int stockCount) {
        this.productId = productId;
        this.name = name;
        this.price = price;
        this.stockCount = stockCount;
    }

    public String getProductId() { return productId; }
    public String getName() { return name; }
    public Money getPrice() { return price; }

    public boolean isInStock(int quantity) { return stockCount >= quantity; }

    public void reduceStock(int quantity) {
        if (quantity > stockCount)
            throw new IllegalStateException("Insufficient stock for '" + name + "'");
        stockCount -= quantity;
    }
}

/** Part of the Order aggregate — cannot exist independently. */
class OrderItem {
    private final Product product;
    private final int quantity;
    private final Money priceAtPurchase;

    public OrderItem(Product product, int quantity) {
        if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
        this.product = product;
        this.quantity = quantity;
        this.priceAtPurchase = product.getPrice(); // snapshot
    }

    public String getProductName() { return product.getName(); }
    public int getQuantity() { return quantity; }

    public Money subtotal() {
        return priceAtPurchase.multiply(quantity);
    }
}

/** Entity and Aggregate Root — owns and controls OrderItems. */
class Order {
    private final String orderId;
    private final String customerId;
    private final List<OrderItem> items = new ArrayList<>();
    private OrderStatus status;
    private final LocalDateTime placedAt;

    public Order(String orderId, String customerId) {
        this.orderId = orderId;
        this.customerId = customerId;
        this.status = OrderStatus.PLACED;
        this.placedAt = LocalDateTime.now();
    }

    public String getOrderId() { return orderId; }
    public OrderStatus getStatus() { return status; }
    public int getItemCount() { return items.size(); }

    public void addItem(Product product, int quantity) {
        if (!product.isInStock(quantity))
            throw new IllegalStateException("'" + product.getName() + "' — insufficient stock");
        items.add(new OrderItem(product, quantity));
        product.reduceStock(quantity);
    }

    public Money calculateTotal() {
        Money total = new Money(0);
        for (OrderItem item : items) {
            total = total.add(item.subtotal());
        }
        return total;
    }

    public void cancel() {
        if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED)
            throw new IllegalStateException("Cannot cancel — order already shipped");
        this.status = OrderStatus.CANCELLED;
    }
}

/** Entity — identified by customerId. */
class Customer {
    private final String customerId;
    private final String name;
    private final String email;
    private final List<Order> orders = new ArrayList<>();

    public Customer(String customerId, String name, String email) {
        this.customerId = customerId;
        this.name = name;
        this.email = email;
    }

    public void placeOrder(Order order) { orders.add(order); }

    public List<Order> orderHistory() {
        return Collections.unmodifiableList(orders);
    }
}

public class Main {
    public static void main(String[] args) {
        Product laptop = new Product("PROD-001", "Laptop Pro 15", new Money(1299.99), 10);
        Product charger = new Product("PROD-002", "USB-C Charger", new Money(29.99), 50);

        Customer customer = new Customer("CUST-001", "Alice", "alice@example.com");

        Order order = new Order("ORD-001", "CUST-001");
        order.addItem(laptop, 1);
        order.addItem(charger, 2);
        customer.placeOrder(order);

        System.out.println("Order " + order.getOrderId() + ": " + order.getItemCount() + " items");
        System.out.println("Total: " + order.calculateTotal());
        System.out.println("Status: " + order.getStatus());

        order.cancel();
        System.out.println("Status: " + order.getStatus());
    }
}

Common Mistakes in Domain Modeling

Mistake 1: Modeling the Database, Not the Domain

The trap: Developers who think in tables create flat, anemic classes that mirror database rows — an OrderTable with customerId, productId, quantity columns crammed into one entity. The domain's natural structure gets lost in relational normalization.

The fix: Model the domain first, think in objects and behaviors. The database schema should derive from the domain model, not the other way around. Your Order object naturally contains OrderItem objects — whether you store them in one table or two is a persistence decision, not a domain decision.

Mistake 2: Skipping Relationship Analysis

The trap: Every relationship is modeled as a simple association. A Customer "has" Orders — but what kind of "has"? If you delete a Customer, should their Orders be deleted (composition) or preserved for accounting records (association)? This distinction drives code structure, cascade behavior, and lifecycle management.

The fix: Apply the relationship types from S4 deliberately. For every connection, ask: "Can this child object exist independently, or only within its parent?" OrderItems cannot exist without an Order → composition. A Product can exist without any particular Order referencing it → association.

Mistake 3: Turning Every Noun into a Class

The trap: The requirements mention "email" → create an Email class. "Total" → create a Total class. "Name" → create a Name class. Not every noun deserves to be a standalone class.

The fix: Apply the "identity and behavior" test. Does this thing need a unique identity? Does it have meaningful behavior? If a noun is purely data with no behavior and no need for independent identity, it's likely an attribute. "Email" is an attribute of Customer. However, if email needs validation logic, formatting behavior, and appears in multiple contexts, it might warrant a Value Object — that's a judgment call explored in Tutorial 2.

Mistake 4: Creating an Anemic Domain Model

The trap: The model is just a list of classes with attributes — no methods, no behavioral logic. All business rules live in external "service" or "manager" classes: OrderService.calculateTotal(order), OrderService.cancel(order). This scatters domain logic across the codebase and violates encapsulation (S3).

The fix: For every entity, ask: "What can this thing do?" An Order knows how to calculate its total, cancel itself, and validate its items. These behaviors belong on the Order class, not in an OrderService utility a thousand lines long. The object should be responsible for its own rules.

Domain Modeling vs Other Modeling Approaches

ApproachFocusOutputWhen to Use
Domain ModelingBusiness concepts, entities, behaviorsConceptual class modelFirst — at the start of every LLD
Data ModelingStorage structure, normalization, indexingER diagrams, database schemasAfter domain modeling, when designing persistence
Use Case ModelingUser interactions, system responsesUse case diagrams (S5)Alongside requirements gathering
Process ModelingWorkflow steps, decisions, branchesActivity diagrams (S5)When mapping complex business workflows

Domain modeling happens first. The other models derive from it. Your domain model tells you what to store (data model), what the user does with it (use case model), and how processes flow through it (process model).

In LLD interviews, domain modeling is embedded in Step 2 of the 4-Step Framework you'll learn in S16: after clarifying requirements, the very next thing you do is identify core objects and relationships — that is domain modeling.

Key Takeaways

  • A domain model captures entities, relationships, and behaviors from the problem space — it is the conceptual blueprint for your code, not the code itself
  • The modeling process follows five steps: gather requirements → extract nouns (classes) → extract verbs (methods) → identify relationships → validate with stakeholders
  • Domain modeling prevents God Classes by forcing you to decompose the problem into distinct entities with focused responsibilities — naturally applying SRP from S6
  • A good domain model creates a ubiquitous language that aligns developers and business stakeholders, reducing miscommunication bugs before they happen
  • In the next tutorial, we'll explore the foundational classification decision in any domain model: Entities vs Value Objects — how to determine whether an object needs identity or is defined purely by its attributes