Domain Modeling: Entities, Value Objects, and Aggregates
Model order flow, coupons, inventory, and payments as rich domain objects — entities, value objects, aggregates, and avoiding anemic models.
Domain Modeling: Entities, Value Objects, and Aggregates
Most backend codebases don't have a design pattern problem — they have a domain modeling problem. Business rules end up scattered across controllers, services, and validators because the domain objects themselves are just bags of getters and setters with no behavior. This guide covers the core domain-modeling vocabulary — entities, value objects, and aggregates — and shows how to turn an anemic order/coupon/inventory model into a rich one where invariants are enforced by the objects themselves, not by hoping every caller remembers to check them.
1. Anemic vs. Rich Domain Models
An anemic domain model is a set of classes with fields, getters, and setters but no behavior — all the logic lives in service classes that pull data out, mutate it, and push it back. A rich domain model puts behavior on the objects that own the data, so invariants can't be bypassed.
// ANEMIC: Order is just a data bag. Nothing stops a caller from putting
// it into an invalid state — there is no enforcement at all.
public class Order {
private String id;
private String customerId;
private List<LineItem> items;
private String status;
private BigDecimal total;
// getters and setters for every field, no other methods
public void setStatus(String status) { this.status = status; }
public void setTotal(BigDecimal total) { this.total = total; }
// ...
}
// All the "business logic" lives outside the object, scattered across services.
public class OrderService {
public void confirmOrder(Order order) {
if (!"PENDING".equals(order.getStatus())) {
throw new IllegalStateException("Cannot confirm");
}
order.setStatus("CONFIRMED");
}
public void cancelOrder(Order order) {
// Did anyone remember this check? What about the check in confirmOrder()?
// Every service method that touches status has to reimplement the rules.
if ("SHIPPED".equals(order.getStatus())) {
throw new IllegalStateException("Cannot cancel shipped order");
}
order.setStatus("CANCELLED");
}
}
// Meanwhile, nothing stops this from compiling and running:
Order order = new Order();
order.setStatus("SHIPPED");
order.setTotal(new BigDecimal("-50.00")); // negative total — no one is watchingThe moment status transitions or invariants are enforced by callers instead of by the object, you have exactly as many enforcement points as you have callers — and every new caller is a new chance to forget a rule.
// RICH: Order enforces its own invariants. It is IMPOSSIBLE to construct
// or mutate an Order into an invalid state through its public API.
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<LineItem> items;
private OrderStatus status;
private Order(OrderId id, CustomerId customerId, List<LineItem> items) {
if (items.isEmpty()) {
throw new IllegalArgumentException("Order must have at least one item");
}
this.id = id;
this.customerId = customerId;
this.items = new ArrayList<>(items);
this.status = OrderStatus.PENDING;
}
public static Order create(CustomerId customerId, List<LineItem> items) {
return new Order(OrderId.generate(), customerId, items);
}
public void confirm() {
if (status != OrderStatus.PENDING) {
throw new IllegalStateTransitionException(status, OrderStatus.CONFIRMED);
}
this.status = OrderStatus.CONFIRMED;
}
public void cancel() {
if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
throw new IllegalStateTransitionException(status, OrderStatus.CANCELLED);
}
this.status = OrderStatus.CANCELLED;
}
public Money total() {
return items.stream()
.map(LineItem::subtotal)
.reduce(Money.ZERO, Money::add);
}
public OrderStatus status() { return status; }
public List<LineItem> items() { return List.copyOf(items); }
}Every caller of order.confirm() gets the same enforcement for free — the rule lives in exactly one place, and it is not possible to construct an Order that skips validation.
Anemic models are especially common in codebases built around JPA entities and MapStruct DTOs. It's easy to let the entity become a pure persistence mirror (@Entity with only @Column fields and Lombok @Data) and push every rule into a service. The result compiles fine and passes code review, but the domain rules end up duplicated wherever the entity is touched — a classic anemic model wearing a JPA annotation.
2. Entities
An entity is a domain object defined by a persistent, unique identity that continues across state changes — two entities with identical field values are still different if their identities differ, and the same entity is still "the same thing" even after every field on it changes.
public class Order {
private final OrderId id; // identity — never changes, defines equality
private OrderStatus status; // state — changes over the entity's lifetime
private List<LineItem> items; // state — can be modified before confirmation
// Equality is based on identity ONLY, not on the current field values.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order other)) return false;
return id.equals(other.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}Order order = repository.findById(orderId);
order.confirm(); // mutates state
order.addNote("Gift order"); // mutates state further
// It is still "the same order" — same identity, evolved state.
// Two Order objects with the same id ARE equal, even if one is stale
// (fetched before confirm()) and one is fresh (fetched after).Identifying entities in the order/coupon/inventory domain
| Concept | Entity? | Why |
|---|---|---|
Order | Yes | Has a persistent ID, moves through a lifecycle (PENDING → CONFIRMED → SHIPPED) |
Customer | Yes | Has a persistent ID, tracked across many orders over years |
InventoryItem (a SKU's stock record) | Yes | Persistent identity (SKU), mutable quantity over time |
Coupon | Yes | Has a code/ID, has a lifecycle (ACTIVE → REDEEMED/EXPIRED) |
Money | No | Two Money instances of $50.00 USD are interchangeable — see Value Objects below |
Address | No (usually) | Two identical addresses are the same address for domain purposes |
The test for "is this an entity?" is: do I need to track this thing's identity across changes over time, and would the business care if I swapped it for an identical-looking copy? You care which specific Order a customer is asking about even after its status changes. You don't care which Money object represents "$50" — any $50 is interchangeable with any other $50.
3. Value Objects
A value object has no identity of its own — it is defined entirely by its attributes, is typically immutable, and two value objects with the same attributes are simply equal, interchangeable in every sense.
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = Objects.requireNonNull(amount).setScale(2, RoundingMode.HALF_EVEN);
this.currency = Objects.requireNonNull(currency);
}
public static final Money ZERO = new Money(BigDecimal.ZERO, Currency.getInstance("USD"));
// No setters — Money is immutable. "Changing" an amount means
// producing a NEW Money instance, never mutating this one.
public Money add(Money other) {
requireSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(BigDecimal factor) {
return new Money(this.amount.multiply(factor), this.currency);
}
public Money subtract(Money other) {
requireSameCurrency(other);
return new Money(this.amount.subtract(other.amount), this.currency);
}
private void requireSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException(this.currency, other.currency);
}
}
// Equality is based on ALL attributes — no identity field at all.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money other)) return false;
return amount.equals(other.amount) && currency.equals(other.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}Value objects as Java records (Java 16+)
Records are close to purpose-built for value objects — immutability, equals()/hashCode()/toString() are generated, and the syntax is compact:
public record ShippingAddress(String line1, String line2, String city, String state, String postalCode, String country) {
// Compact constructor — validation runs on every construction path
public ShippingAddress {
Objects.requireNonNull(line1, "line1 is required");
Objects.requireNonNull(city, "city is required");
Objects.requireNonNull(postalCode, "postalCode is required");
if (!postalCode.matches("\\d{5,6}")) {
throw new IllegalArgumentException("Invalid postal code: " + postalCode);
}
}
}
public record CouponCode(String value) {
public CouponCode {
if (value == null || !value.matches("[A-Z0-9]{6,12}")) {
throw new IllegalArgumentException("Invalid coupon code format: " + value);
}
}
}
public record DiscountPercentage(BigDecimal value) {
public DiscountPercentage {
if (value.compareTo(BigDecimal.ZERO) < 0 || value.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new IllegalArgumentException("Discount must be between 0 and 100");
}
}
}Wrapping primitives in value objects (CouponCode instead of String, Quantity instead of int) is called "making illegal states unrepresentable." A raw String couponCode parameter can hold null, an empty string, or garbage — every method that receives it has to re-validate. A CouponCode value object validates once, at construction, and every method that accepts one gets the guarantee for free. This is one of the highest-leverage domain modeling habits you can build.
Entity vs. value object
| Aspect | Entity | Value Object |
|---|---|---|
| Identity | Has a persistent, unique ID | No identity — defined by its attributes |
| Equality | By identity (equals compares IDs) | By value (equals compares all fields) |
| Mutability | Often mutable (state changes over lifecycle) | Should be immutable |
| Lifecycle | Created, changes over time, eventually archived/deleted | Created, used, discarded — replaced wholesale when it needs to "change" |
| Backend example | Order, Customer, InventoryItem | Money, ShippingAddress, CouponCode, Quantity |
| Persistence | Typically its own table with a primary key | Often embedded/inlined into the owning entity's row |
4. Aggregates and Aggregate Roots
An aggregate is a cluster of entities and value objects treated as a single consistency boundary. One entity within it is the aggregate root — the only object external code is allowed to reference directly. Everything else inside the aggregate is reached only through the root, which is what lets the root enforce invariants that span multiple objects.
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<LineItem> items = new ArrayList<>(); // NOT exposed as a mutable list
private OrderStatus status = OrderStatus.PENDING;
private static final int MAX_LINE_ITEMS = 50;
// External code can NEVER add a LineItem directly — it must go through
// the aggregate root, which enforces the invariant (item limit, no
// duplicate SKUs, no modification after confirmation) every time.
public void addItem(Sku sku, Quantity quantity, Money unitPrice) {
requireModifiable();
if (items.size() >= MAX_LINE_ITEMS) {
throw new OrderLimitExceededException(MAX_LINE_ITEMS);
}
Optional<LineItem> existing = items.stream()
.filter(item -> item.sku().equals(sku))
.findFirst();
if (existing.isPresent()) {
existing.get().increaseQuantity(quantity); // consolidate, don't duplicate
} else {
items.add(new LineItem(sku, quantity, unitPrice));
}
}
public void removeItem(Sku sku) {
requireModifiable();
items.removeIf(item -> item.sku().equals(sku));
if (items.isEmpty()) {
throw new IllegalStateException("Order must retain at least one item");
}
}
public void confirm() {
if (status != OrderStatus.PENDING) {
throw new IllegalStateTransitionException(status, OrderStatus.CONFIRMED);
}
if (items.isEmpty()) {
throw new IllegalStateException("Cannot confirm an order with no items");
}
this.status = OrderStatus.CONFIRMED;
}
private void requireModifiable() {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException("Cannot modify a " + status + " order");
}
}
// Read access returns an immutable copy — callers can inspect, never mutate.
public List<LineItem> items() {
return List.copyOf(items);
}
}// External code NEVER does this — LineItem is not independently addressable:
// lineItemRepository.save(new LineItem(...)); // WRONG — bypasses Order's invariants
// External code always goes through the aggregate root:
Order order = orderRepository.findById(orderId);
order.addItem(sku, quantity, unitPrice); // enforces MAX_LINE_ITEMS, dedup, status check
orderRepository.save(order);Why aggregate boundaries matter: transactional consistency
An aggregate is also a transaction boundary. Everything inside one aggregate should be saved in a single transaction, and invariants that span multiple objects (like "an order can have at most 50 line items" or "total quantity reserved cannot exceed stock on hand") should be enforced inside the aggregate, not by a service coordinating two separate saves. If you find yourself needing a distributed transaction or a saga to keep two objects consistent, that's often a signal they belong in two separate aggregates that should be eventually consistent, not one.
Choosing aggregate boundaries in the order/inventory domain
A common mistake is making the aggregate too large — for example, putting Order and InventoryItem in the same aggregate because an order "affects" inventory. This is wrong: they have independent lifecycles, are modified by different actors, and coupling them would mean every order placement takes a lock on inventory rows, killing concurrency.
// Order and InventoryItem are SEPARATE aggregates.
// They communicate through domain events, not direct object references.
public class Order {
// ... as above — does NOT hold a reference to InventoryItem
}
public class InventoryItem {
private final Sku sku;
private int quantityOnHand;
private int quantityReserved;
// InventoryItem enforces ITS OWN invariant independently —
// it doesn't trust Order to have checked stock correctly.
public void reserve(int quantity) {
int available = quantityOnHand - quantityReserved;
if (quantity > available) {
throw new InsufficientStockException(sku, quantity, available);
}
this.quantityReserved += quantity;
}
public void release(int quantity) {
this.quantityReserved = Math.max(0, this.quantityReserved - quantity);
}
}
// A coordinating application service — NOT the aggregates themselves —
// orchestrates the two-aggregate workflow, one aggregate save per transaction.
@Service
public class OrderPlacementService {
private final OrderRepository orderRepository;
private final InventoryRepository inventoryRepository;
private final ApplicationEventPublisher events;
@Transactional
public Order placeOrder(OrderRequest request) {
Order order = Order.create(request.customerId(), request.items());
orderRepository.save(order);
for (LineItem item : order.items()) {
InventoryItem stock = inventoryRepository.findBySku(item.sku());
stock.reserve(item.quantity().value()); // throws InsufficientStockException if unavailable
inventoryRepository.save(stock);
}
events.publishEvent(new OrderPlacedEvent(order.id()));
return order;
}
}| Signal | Same aggregate | Separate aggregates |
|---|---|---|
| Must always be consistent within one transaction | Yes | No — eventual consistency is acceptable |
| Modified by the same use case, same actor | Usually | Often modified independently (order by customer, inventory by warehouse system) |
| One's lifecycle is entirely owned by the other (can't exist independently) | Yes (e.g., LineItem can't exist without its Order) | No — each has an independent lifecycle |
| High write concurrency expected on both | — | Keep separate to avoid lock contention |
5. Keeping Domain Logic Out of Controllers and Services
The most common domain-modeling failure in Spring Boot codebases isn't a missing concept — it's business rules leaking into the wrong layer because it was the path of least resistance under deadline pressure.
// BAD: business logic (coupon eligibility, discount calculation, state
// transition rules) living in the controller. Untestable without spinning
// up the whole web layer, and impossible to reuse from a batch job or event handler.
@RestController
public class OrderController {
@PostMapping("/orders/{orderId}/apply-coupon")
public ResponseEntity<?> applyCoupon(@PathVariable String orderId, @RequestBody CouponRequest request) {
Order order = orderRepository.findById(orderId).orElseThrow();
Coupon coupon = couponRepository.findByCode(request.getCode()).orElseThrow();
// Business rules embedded directly in the HTTP layer:
if (coupon.getExpiryDate().isBefore(LocalDate.now())) {
return ResponseEntity.badRequest().body("Coupon expired");
}
if (order.getTotal().compareTo(coupon.getMinOrderValue()) < 0) {
return ResponseEntity.badRequest().body("Order below minimum for this coupon");
}
if (coupon.getUsageCount() >= coupon.getUsageLimit()) {
return ResponseEntity.badRequest().body("Coupon usage limit reached");
}
BigDecimal discount = order.getTotal()
.multiply(coupon.getPercentage())
.divide(BigDecimal.valueOf(100));
order.setTotal(order.getTotal().subtract(discount));
orderRepository.save(order);
return ResponseEntity.ok(order);
}
}// GOOD: the controller only translates HTTP <-> domain calls.
// All eligibility and discount logic lives on the domain objects themselves.
@RestController
public class OrderController {
private final OrderApplicationService orderApplicationService;
@PostMapping("/orders/{orderId}/apply-coupon")
public ResponseEntity<OrderResponse> applyCoupon(@PathVariable String orderId,
@RequestBody CouponRequest request) {
Order order = orderApplicationService.applyCoupon(new OrderId(orderId), new CouponCode(request.code()));
return ResponseEntity.ok(OrderResponse.from(order));
}
}
// The domain objects enforce the rules — testable in pure unit tests,
// with zero Spring context, zero HTTP, zero database.
public class Coupon {
private final CouponCode code;
private final DiscountPercentage percentage;
private final Money minOrderValue;
private final LocalDate expiryDate;
private int usageCount;
private final int usageLimit;
public boolean isEligibleFor(Order order) {
return !isExpired() && !isExhausted() && order.total().isAtLeast(minOrderValue);
}
public Money discountFor(Order order) {
if (!isEligibleFor(order)) {
throw new CouponNotEligibleException(code, order.id());
}
return order.total().multiply(percentage.value().divide(BigDecimal.valueOf(100)));
}
public void recordRedemption() {
if (isExhausted()) {
throw new CouponExhaustedException(code);
}
this.usageCount++;
}
private boolean isExpired() { return expiryDate.isBefore(LocalDate.now()); }
private boolean isExhausted() { return usageCount >= usageLimit; }
}
@Service
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final CouponRepository couponRepository;
@Transactional
public Order applyCoupon(OrderId orderId, CouponCode code) {
Order order = orderRepository.findById(orderId).orElseThrow();
Coupon coupon = couponRepository.findByCode(code).orElseThrow();
Money discount = coupon.discountFor(order); // throws if not eligible
order.applyDiscount(discount);
coupon.recordRedemption();
orderRepository.save(order);
couponRepository.save(coupon);
return order;
}
}A useful litmus test: could you unit test this business rule without starting a Spring context, hitting a database, or mocking HttpServletRequest? If the answer is no, the rule almost certainly lives in the wrong layer. Domain rules — eligibility, discount math, state transitions, invariant checks — belong on domain objects (or domain services that operate purely on domain objects), which makes them testable with a plain new Coupon(...) and a plain JUnit assertion.
6. Domain Services — When Logic Doesn't Belong to One Entity
Not every rule fits naturally on a single entity. When a piece of domain logic genuinely spans multiple aggregates and doesn't conceptually "belong" to either one, a domain service is the right home — a stateless class expressing a domain concept, distinct from an application service that just orchestrates persistence and transactions.
// This logic doesn't belong to Order (which shouldn't know about InventoryItem
// internals) or to InventoryItem (which shouldn't know about Order). It's a
// genuine cross-aggregate domain concept: "can this order be fulfilled right now?"
public class OrderFulfillabilityChecker {
public FulfillabilityResult check(Order order, Map<Sku, InventoryItem> stockLevels) {
List<Sku> unavailable = new ArrayList<>();
for (LineItem item : order.items()) {
InventoryItem stock = stockLevels.get(item.sku());
if (stock == null || !stock.canFulfill(item.quantity())) {
unavailable.add(item.sku());
}
}
return unavailable.isEmpty()
? FulfillabilityResult.fulfillable()
: FulfillabilityResult.blockedBy(unavailable);
}
}| Layer | Responsibility | Example |
|---|---|---|
| Entity / value object | Enforce its own invariants | Order.confirm(), Coupon.discountFor() |
| Domain service | Logic that spans multiple aggregates, expressed in domain terms | OrderFulfillabilityChecker |
| Application service | Orchestrates transactions, persistence, and calls into the domain | OrderApplicationService.applyCoupon() |
| Controller | Translates HTTP requests/responses to and from application service calls | OrderController |
7. Production Observations
JPA entities and domain entities don't have to be the same class, but in most Java shops they are — and that's fine as long as you're deliberate about it. Annotate your rich domain model directly with @Entity/@Embeddable where practical; only introduce a separate persistence model (and mapping layer) when the persistence shape and domain shape genuinely diverge (e.g., a denormalized read model, or a legacy schema you can't change). Adding a mapping layer "for purity" before you need one is its own form of over-engineering.
// A rich domain model can be a JPA entity directly — no separate
// "persistence model" needed for the common case.
@Entity
@Table(name = "orders")
public class Order {
@Id
private String id;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@ElementCollection
@CollectionTable(name = "order_line_items", joinColumns = @JoinColumn(name = "order_id"))
private List<LineItem> items = new ArrayList<>();
protected Order() {} // required by JPA — package-private/protected, never public
public static Order create(CustomerId customerId, List<LineItem> items) {
Order order = new Order();
order.id = OrderId.generate().value();
order.status = OrderStatus.PENDING;
order.items = new ArrayList<>(items);
return order;
}
public void confirm() {
if (status != OrderStatus.PENDING) {
throw new IllegalStateTransitionException(status, OrderStatus.CONFIRMED);
}
this.status = OrderStatus.CONFIRMED;
}
// Invariants are STILL enforced through methods — JPA's no-args constructor
// and field access don't bypass them for normal application code paths.
}Key takeaways
- An anemic domain model pushes every rule into services, which means every caller has to remember to enforce it — a rich model enforces invariants in one place, the object itself.
- Entities are defined by identity that persists across state changes; value objects are defined entirely by their attributes and should be immutable.
- Wrapping primitives (
CouponCode,Money,Quantity) in value objects makes illegal states unrepresentable and moves validation to a single construction point instead of every call site. - An aggregate is a consistency boundary with one root — external code reaches internal entities only through the root, which is what lets the root actually enforce cross-object invariants.
- Aggregate boundaries should follow transactional consistency needs and independent lifecycles, not just "these things are related" —
OrderandInventoryItemare separate aggregates coordinated by an application service, not one aggregate. - If a business rule can't be unit tested without a Spring context, a database, or an HTTP mock, it's very likely living in the wrong layer.
- Domain services hold logic that genuinely spans multiple aggregates; application services only orchestrate transactions and persistence — don't let business rules creep into the application service layer either.
- JPA entities and domain entities can be the same class in most Java backend codebases — don't add a separate persistence-mapping layer until the persistence shape and domain shape actually diverge.
Interview Questions
- What is the core difference between an entity and a value object? Give three examples of each from an order-processing domain.
- What is an anemic domain model, and why is it a problem even though the code still "works"?
- How would you refactor a service method that checks
order.getStatus().equals("PENDING")before mutating an order into a rich domain model? - What is an aggregate root, and why should external code never directly reference entities inside an aggregate?
- Why should
OrderandInventoryItemtypically be modeled as separate aggregates rather than one? What would go wrong if they were combined? - How do value objects help make "illegal states unrepresentable"? Give an example using a coupon code or a money amount.
- What's the difference between a domain service and an application service? Give an example of logic that belongs in each.
- Why is
Moneya good candidate for a value object instead of a rawBigDecimalfield? What invariants does it protect? - How would you enforce that an
Ordercan never have a negative total, using rich domain modeling instead of a validation annotation? - What test would you apply to decide whether a business rule belongs on a domain entity versus in a Spring
@Serviceclass? - Can a JPA
@Entityalso be a well-designed domain entity? What's the risk of treating it purely as a persistence mirror? - Why does an aggregate act as a transaction boundary? What's the risk of writing to two aggregates in one transaction versus using eventual consistency?
- How would you model coupon eligibility rules (expiry, minimum order value, usage limit) so they can be unit tested without a database?
- What's the difference between a
List<LineItem>field that's mutable and directly exposed via a getter, versus one that returnsList.copyOf(items)? Why does it matter for aggregate integrity?