Entities

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Strategic Design for Microservices draws the bounded-context boundary and Structuring the Code lays out the package layout inside it; this page picks up the first of the tactical building blocks that actually live inside that domain package: the entity. An entity is a domain object defined by a thread of continuous identity rather than by the values of its attributes at any one moment — two entities with identical attributes are still different entities if their identities differ, and one entity is still "the same" object even after every one of its attributes has changed. The running Order aggregate root introduced in Structuring the Code is also the running entity example throughout this page, shown with a Java 21 / Spring Boot 4.1.x / Spring Data JPA baseline consistent with the rest of this section.

Identity as the defining property

Ask what makes "the same order" the same order across time, and the answer has nothing to do with its current line items or status. An Order that was APPROVAL_PENDING an hour ago and is APPROVED now is still the same order — every attribute on it may have changed, including its status, but nothing about "which order this is" has. The same holds across representations: an Order freshly loaded from a database row and the in-memory Order object a service held onto ten minutes ago are the same order, and so are two separate Order object instances produced by loading the same row twice in two different requests. What ties all of these together — different points in time, different in-memory instances, a database row vs. an object graph — is a single, stable value that never changes for the lifetime of the entity: its identifier, OrderId.

This is also why identity has to be judged separately from reference equality. Two Order instances can be ==-different (different objects on the heap, possibly even different JVMs) and still need to be treated as equal by the domain, because equality for an entity is defined by identity, not by object reference or by attribute values. Vernon’s DDD Destilado ch. 5 ("¿Qué es una entidad?") makes exactly this point the defining property of the pattern: an entity is unique, not because of its attributes, but because of a thread of continuity and identity that spans its whole lifecycle, however much its attributes change along the way.

Identities themselves can be created a handful of ways, and the choice has real consequences:

  • Client-generated identity — the identifier is generated by the code creating the entity, before anything is persisted. OrderId.generate() in the code below does exactly this, wrapping a randomly generated UUID.

  • Database-generated identity — a sequence or auto-increment column assigns the identifier at insert time, so the entity does not have a real identifier until the row exists.

  • Natural key — an identifier already meaningful in the domain (a national tax id, an ISBN) is reused as the entity’s identity, with no separate surrogate key generated at all.

  • User-supplied code — an external actor (a customer, another system) supplies an identifier such as an order confirmation code, and the entity is keyed on it directly.

The running example deliberately picks client-generated UUIDs, and Richardson’s Microservices Patterns 5.1.3 gives the concrete reason this matters for a microservice specifically, not just an academic preference: because OrderId.generate() produces the identifier before Order.place(…​) even returns, PlaceOrderService can build an OrderPlaced event carrying the real OrderId in the very same local transaction as the insert — there is no window where the aggregate exists but its downstream event cannot yet reference it, the way there would be if the database had to assign the id first. It also enables idempotency at the edge: if an HTTP client retries a "place order" call after a timeout, having the client supply (or having the original response echo back) the same OrderId on both attempts lets the service recognize the second call as a repeat of the first rather than a second order, instead of relying on some separate idempotency-key mechanism bolted on afterward. A database-generated sequence cannot offer either property, because the identifier simply does not exist until after the insert commits.

Rich behavior instead of getters/setters

An anemic domain model — Fowler’s term for a domain object that is little more than a bag of fields with public getters and setters and no behavior of its own — would let Order look something like this:

public class AnemicOrder {
    private OrderStatus status;
    private List<OrderLine> lines;

    public void setStatus(OrderStatus status) {
        this.status = status;
    }

    public void setLines(List<OrderLine> lines) {
        this.lines = lines;
    }
    // ... plain getters for everything else
}

Every rule about when status is allowed to change, and what has to stay true about lines when it does, now lives outside AnemicOrder — in whichever application-service method happens to call setStatus(…​), duplicated (or, worse, half-duplicated) at every call site that touches an order. The real Order entity shown below never exposes a raw setter for status or lines; every state change goes through an intention-revealing method — approve(), reject(reason), cancel(), removeLine(line) — and each one enforces its own rule before it lets the change happen: approve() and reject(reason) both call requireStatus(APPROVAL_PENDING) so neither can fire from the wrong state, removeLine(line) recomputes the remaining total and refuses to drop the order below MINIMUM_ORDER_AMOUNT, and cancel() inlines the one check that differs from the other two (cancellable from either APPROVAL_PENDING or APPROVED). The invariant lives in exactly one place — the method itself — instead of in the discipline of every caller remembering to check it first.

Read together, approve(), reject(reason) and cancel() describe a small, explicit state machine over OrderStatus: an order starts APPROVAL_PENDING when Order.place(…​) creates it, moves to either APPROVED or REJECTED from there, can be cancelled from either APPROVAL_PENDING or APPROVED, and has no transition defined out of REJECTED or CANCELLED — both are terminal, which is exactly why requireStatus and the inline check in cancel() throw rather than silently no-op when called from the wrong state.

stateDiagram-v2 [*] --> APPROVAL_PENDING: place APPROVAL_PENDING --> APPROVED: approve APPROVAL_PENDING --> REJECTED: reject APPROVAL_PENDING --> CANCELLED: cancel APPROVED --> CANCELLED: cancel REJECTED --> [*] CANCELLED --> [*]

The last piece rich behavior depends on is getting equals/hashCode right, and the code below defines both purely in terms of id, never lines or status. This is not an oversight — comparing on the mutable attributes would actively produce wrong answers. Picture two requests loading the same order at nearly the same moment, one finishing just before an approve() call commits and one just after: if equals compared status too, those two in-memory Order objects — both legitimately representing the same order, just observed a few milliseconds apart — would compare unequal, even though by identity they are unambiguously the same order. Basing equality on id alone is what keeps "same order" a stable, well-defined question regardless of which attributes happen to have raced ahead in one copy or the other.

Code: the Order entity

This is the same canonical Order that Aggregates goes on to build on as an aggregate root — the aggregate-specific concerns (invariant scope, transaction boundary, the "one aggregate per transaction" rule) belong to that page. Here, the same class is read purely as an entity: its identity (OrderId), its behavior-first API, and the OrderStatus state machine above.

package com.example.order.domain;

import com.example.order.application.PlaceOrderCommand;
import com.example.order.domain.event.OrderApproved;
import com.example.order.domain.event.OrderCancelled;
import com.example.order.domain.event.OrderPlaced;
import com.example.order.domain.event.OrderRejected;
import com.example.shared.CustomerId;
import com.example.shared.Money;
import com.example.shared.ProductId;
import com.example.shared.event.DomainEvent;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Currency;
import java.util.List;
import java.util.Objects;
import java.util.UUID;

public record OrderId(UUID value) {
    public static OrderId generate() {
        return new OrderId(UUID.randomUUID());
    }
}

public record OrderLine(ProductId productId, int quantity, Money unitPrice) {
    public Money lineTotal() {
        return unitPrice.multiply(quantity);
    }
}

public enum OrderStatus {
    APPROVAL_PENDING, APPROVED, REJECTED, CANCELLED
}

/**
 * What the Order.place(PlaceOrderCommand) factory hands back to its caller: the newly
 * created aggregate plus the domain events it raised, so PlaceOrderService can save one
 * and publish the other without Order depending on any publishing mechanism itself.
 */
public record OrderCreation(Order order, List<DomainEvent> events) {
}

public class Order {

    private static final Money MINIMUM_ORDER_AMOUNT = new Money(BigDecimal.valueOf(50), Currency.getInstance("USD"));

    private final OrderId id;
    private final CustomerId customerId;
    private final List<OrderLine> lines;
    private OrderStatus status;
    private long version;

    private Order(OrderId id, CustomerId customerId, List<OrderLine> lines, OrderStatus status) {
        this.id = id;
        this.customerId = customerId;
        this.lines = new ArrayList<>(lines);
        this.status = status;
    }

    /** Creation: a brand-new order, always born APPROVAL_PENDING -- and only if its lines are valid. */
    public static OrderCreation place(PlaceOrderCommand command) {
        requireValidLines(command.lines());
        OrderId id = OrderId.generate();
        Order order = new Order(id, command.customerId(), command.lines(), OrderStatus.APPROVAL_PENDING);
        OrderPlaced event = new OrderPlaced(id, command.customerId(), List.copyOf(order.lines),
                order.totalAmount(), Instant.now());
        return new OrderCreation(order, List.of(event));
    }

    /** Reconstitution: rebuilds an order already persisted, e.g. from a JPA row -- never raises events. */
    public static Order reconstitute(OrderId id, CustomerId customerId, List<OrderLine> lines,
                                      OrderStatus status, long version) {
        Order order = new Order(id, customerId, lines, status);
        order.version = version;
        return order;
    }

    /**
     * The creation-time invariants: at least one line, and a total that meets the minimum order amount.
     * Public and static on purpose, so an adapter that has to validate before an Order exists (see
     * Listen to Yourself) runs exactly this check rather than a copy of it.
     */
    public static void requireValidLines(List<OrderLine> lines) {
        if (lines.isEmpty()) {
            throw new IllegalArgumentException("An order must have at least one line");
        }
        Money total = totalOf(lines);
        if (total.amount().compareTo(MINIMUM_ORDER_AMOUNT.amount()) < 0) {
            throw new IllegalArgumentException("Order total " + total + " is below the minimum order amount of "
                    + MINIMUM_ORDER_AMOUNT);
        }
    }

    public List<DomainEvent> approve() {
        requireStatus(OrderStatus.APPROVAL_PENDING);
        status = OrderStatus.APPROVED;
        return List.of(new OrderApproved(id, Instant.now()));
    }

    public List<DomainEvent> reject(String reason) {
        requireStatus(OrderStatus.APPROVAL_PENDING);
        status = OrderStatus.REJECTED;
        return List.of(new OrderRejected(id, reason, Instant.now()));
    }

    public List<DomainEvent> cancel() {
        if (status != OrderStatus.APPROVAL_PENDING && status != OrderStatus.APPROVED) {
            throw new IllegalStateException("Cannot cancel order " + id + " in status " + status);
        }
        status = OrderStatus.CANCELLED;
        return List.of(new OrderCancelled(id, Instant.now()));
    }

    /** The line-mutation method that enforces the minimum-order invariant -- see Aggregates. */
    public void removeLine(OrderLine line) {
        requireModifiable();
        List<OrderLine> candidate = new ArrayList<>(lines);
        if (!candidate.remove(line)) {
            throw new IllegalArgumentException("Order " + id + " does not contain line " + line);
        }
        Money remainingTotal = candidate.stream()
                .map(OrderLine::lineTotal)
                .reduce(Money::add)
                .orElse(new Money(BigDecimal.ZERO, MINIMUM_ORDER_AMOUNT.currency()));
        if (remainingTotal.amount().compareTo(MINIMUM_ORDER_AMOUNT.amount()) < 0) {
            throw new IllegalStateException("Removing " + line + " would drop order " + id
                    + " below the minimum order amount of " + MINIMUM_ORDER_AMOUNT
                    + " (remaining would be " + remainingTotal + ")");
        }
        lines.remove(line);
    }

    public Money totalAmount() {
        return totalOf(lines);
    }

    private static Money totalOf(List<OrderLine> lines) {
        return lines.stream().map(OrderLine::lineTotal).reduce(Money::add).orElseThrow();
    }

    public OrderId id() {
        return id;
    }

    public CustomerId customerId() {
        return customerId;
    }

    public List<OrderLine> lines() {
        return List.copyOf(lines);
    }

    public OrderStatus status() {
        return status;
    }

    public long version() {
        return version;
    }

    private void requireStatus(OrderStatus expected) {
        if (status != expected) {
            throw new IllegalStateException("Expected order " + id + " to be " + expected + " but was " + status);
        }
    }

    private void requireModifiable() {
        if (status != OrderStatus.APPROVAL_PENDING) {
            throw new IllegalStateException("Cannot modify lines of order " + id + " in status " + status);
        }
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }
        if (!(other instanceof Order order)) {
            return false;
        }
        return Objects.equals(id, order.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

JPA mapping with a typed identifier

OrderId is a small record wrapping a UUID, not a UUID itself — deliberately, so that a method signature like Order.reconstitute(OrderId id, …​) cannot be called with the wrong kind of identifier by mistake the way it could if every aggregate’s id were a bare UUID. That safety only pays off if the typed identifier survives all the way down to the persistence layer, so the mapping mechanism used here is an AttributeConverter<OrderId, UUID>: a small, @Converter(autoApply = true) class that tells Spring Data JPA how to turn an OrderId into the raw UUID column value on the way out, and back into an OrderId on the way in, so that every JPA entity and every derived-query method can simply declare its id column as OrderId and never write .value() by hand:

package com.example.order.infrastructure;

import com.example.order.domain.OrderId;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;

import java.util.UUID;

@Converter(autoApply = true)
public class OrderIdConverter implements AttributeConverter<OrderId, UUID> {

    @Override
    public UUID convertToDatabaseColumn(OrderId orderId) {
        return orderId == null ? null : orderId.value();
    }

    @Override
    public OrderId convertToEntityAttribute(UUID uuid) {
        return uuid == null ? null : new OrderId(uuid);
    }
}

Derived from the Jakarta Persistence AttributeConverter contract as Spring Data JPA applies it — see Spring Data JPA reference — Persisting entities, and Entities & Identifiers in this site’s Hibernate reference for the converter and identifier mapping options with their official Hibernate and Jakarta Persistence links.

With autoApply = true, any JPA entity field typed OrderId — the primary key on an OrderJpaEntity, or a foreign-key-shaped OrderId field on some other entity that merely references an order — picks up this conversion automatically, with no @Convert annotation needed at each usage site. The alternative worth names is @EmbeddedId: marking OrderId itself @Embeddable and mapping it as the entity’s embedded identifier instead of converting it to a raw column. A converter is the right default whenever the identifier really is a single column underneath, which is the common case and exactly OrderId’s shape here; `@EmbeddedId earns its extra ceremony only for a genuinely composite key — an identifier that is naturally more than one column (e.g. a tenant id plus a per-tenant sequence) and cannot be represented as a single converted value at all. Mapping the rest of the Order aggregate — the OrderJpaEntity that uses this converter for its id column, its OrderLine collection, and the OrderRepository implementation that calls Order.reconstitute(…​) to rebuild the domain object from a row — is completed on Repositories, Factories, and Domain Services; this page’s job is just the identifier-mapping piece that everything else in that later mapping depends on.

Code: a JUnit 5 test asserting identity-based equality

The test below is the executable version of the equality argument made above: two Order instances built with the same OrderId but different status values must still be equals() and share a hashCode(), because equality is defined on identity alone; an Order built with a different id must compare unequal regardless of how similar its other attributes are.

package com.example.order.domain;

import com.example.shared.CustomerId;
import com.example.shared.Money;
import com.example.shared.ProductId;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.util.Currency;
import java.util.List;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;

class OrderEqualityTest {

    private static final Currency USD = Currency.getInstance("USD");

    @Test
    void ordersWithTheSameIdAreEqualRegardlessOfStatus() {
        OrderId sharedId = OrderId.generate();
        CustomerId customerId = new CustomerId(UUID.randomUUID());
        List<OrderLine> lines = List.of(
                new OrderLine(new ProductId(UUID.randomUUID()), 1, new Money(BigDecimal.valueOf(75), USD)));

        Order pending = Order.reconstitute(sharedId, customerId, lines, OrderStatus.APPROVAL_PENDING, 0L);
        Order approved = Order.reconstitute(sharedId, customerId, lines, OrderStatus.APPROVED, 1L);

        assertEquals(pending, approved, "orders sharing an id must be equal even with different status");
        assertEquals(pending.hashCode(), approved.hashCode(), "equal orders must share a hash code");
    }

    @Test
    void ordersWithDifferentIdsAreNeverEqual() {
        CustomerId customerId = new CustomerId(UUID.randomUUID());
        List<OrderLine> lines = List.of(
                new OrderLine(new ProductId(UUID.randomUUID()), 1, new Money(BigDecimal.valueOf(75), USD)));

        Order first = Order.reconstitute(OrderId.generate(), customerId, lines, OrderStatus.APPROVAL_PENDING, 0L);
        Order second = Order.reconstitute(OrderId.generate(), customerId, lines, OrderStatus.APPROVAL_PENDING, 0L);

        assertNotEquals(first, second, "orders with different ids must never be equal");
    }
}

The test uses JUnit Jupiter’s @Test and Assertions — see the JUnit 5 User Guide.

ordersWithTheSameIdAreEqualRegardlessOfStatus is the test that would fail immediately if equals/hashCode ever grew to compare status or lines — which is exactly the regression it exists to catch, since that mistake would otherwise only show up much later, as two legitimately-the-same orders mysteriously failing to deduplicate in a Set<Order> or a JPA persistence-context identity check.