Bag vs object
DTO vs domain model
A DTO is a data bag for transport. A domain object owns behavior and invariants. Do not use one class for both jobs. Example: TicketDto has public fields, getters, setters, and no methods. Someone uses it as the ticket. Fees are computed in ParkingService by reading dto.entry and dto.exit. That is an dressed as a DTO.
DTO — museum replica
A : fields the UI needs. You can print it, serialize it, send it across a process. You cannot close it. Changing a field on the replica does not change the lot.
- Public or simple accessors
- No InvalidTransition
- Allowed to look like JSON
Domain — the real ticket
Identity, invariants, . Private status. close() is the only way to CLOSED. This object never leaks to the kiosk as a live reference.
- Ticket.close(), Spot.tryOccupy()
- Money as a value object
- No Jackson annotations required
Java: bag vs object
// DTO — snapshot for the edge
final class TicketView {
public final String id;
public final String status;
public final String plate;
TicketView(String id, String status, String plate) {
this.id = id;
this.status = status;
this.plate = plate;
}
}
// Domain — behavior and invariants
class Ticket {
private TicketStatus status;
Receipt close(Instant exit, PricingStrategy pricing) {
if (status != TicketStatus.ACTIVE) throw new InvalidTransition(status, TicketStatus.CLOSED);
status = TicketStatus.CLOSED;
return new Receipt(id, pricing.fee(this, exit));
}
}One class, three jobs
Ticket with @Column, @JsonProperty, and close() will change for three reasons: schema, wire, and rules. Split the snapshots. Keep the behavior. The persistence twin of this page is domain vs persistence model. The edge twin is request/response models. Rich vs anemic is rich vs anemic.
Services that only call getters
ParkingService reading ticket.getEntry() and writing ticket.setFee() is the anemic model. Ask ticket.close(clock, pricing). Application services still exist: they load the aggregate, call the command, save. They do not steal close().
Java: map out, command in
class ParkingService {
private final ParkingLot lot;
TicketView view(TicketId id) {
Ticket ticket = lot.find(id).orElseThrow(() -> new UnknownTicket(id));
return new TicketView(ticket.id().value(), ticket.status().name(), ticket.plate().value());
}
Receipt exit(TicketId id) {
return lot.exit(id); // domain command, not dto.setStatus("CLOSED")
}
}Mistakes
- One class for JSON, SQL, and rules — three reasons to change. Split the snapshots.
- Service does all the work on getters — that is anemic. Put close() on Ticket.
- Returning the live Ticket as a “DTO” — the kiosk can call close() twice or skip payment. Return TicketView.
HLD mapping
Across services you will version JSON contracts — API compatibility. That is DTO evolution. Domain Ticket still closes the same way. Do not version close() because a mobile app added a field.
Fewer classes is not cheaper
One Ticket class with JSON, SQL, and close() looks small. It changes when the mobile app wants a color, when finance wants a new column, and when CLOSED→ACTIVE must stay illegal. Three reasons, one type, every change is a fight. TicketView, Ticket, and a row mapper are more files and fewer fights. Interviewers who say “too many classes” still want close() on an object that cannot be a JSON bag.
An untyped process(Object) method is the extreme DTO: one bag, no behavior, every rule in a service. park(Vehicle) returning Ticket is the extreme domain: named command, typed result. The healthy design uses both, at different layers. The unhealthy design uses one class for both layers.
Java: anemic service vs rich command
// Anemic — service owns the rules
class ParkingService {
Receipt exit(TicketDto dto, Instant now) {
if (!"ACTIVE".equals(dto.status)) throw new IllegalStateException();
dto.status = "CLOSED";
dto.fee = pricing.calculate(dto.entry, now);
return new Receipt(dto.id, dto.fee);
}
}
// Rich — Ticket owns the rules
class ParkingLot {
Receipt exit(TicketId id) {
Ticket ticket = tickets.findById(id).orElseThrow(() -> new UnknownTicket(id));
return ticket.close(clock.instant(), pricing);
}
}DTO may be mutable for mapping
Frameworks like setters. Fine at the edge. Do not let those setters become Ticket.setStatus.
Domain should not be a JavaBean
Private fields, commands, no public status setter. Equality by id for Ticket, by value for Money.
Exercises
- A reviewer says “just use Ticket everywhere, fewer classes.” Name two fields that would then appear on Ticket that do not belong.
- Rewrite a ParkingService that calls setStatus("CLOSED") into a single domain command.
- Is Receipt a DTO or a domain object? When would you split it into Receipt and ReceiptView?
Worked scene: the kiosk prints a view
park returns Ticket to the lot’s in-process caller. The controller maps Ticket → ParkResponse with id and spot. The kiosk never holds Ticket, so it cannot call close() when the driver is still shopping. If you return Ticket across that boundary “to save a class,” you have given the kiosk a loaded gun. TicketView is the safety. close() stays on the object that owns status.
Receipt can stay a domain object if it has fee math. ReceiptView is the printout: amount string, currency, ticket id. Split when the printer wants a barcode the domain should not store. Do not split when Receipt is already a frozen value with no setters. Frozen values can cross a boundary. Living entities cannot.
final class Receipt {
private final TicketId ticketId;
private final Money fee;
Money fee() { return fee; }
}
final class ReceiptView {
final String ticketId;
final String amount;
static ReceiptView from(Receipt receipt) {
return new ReceiptView(receipt.ticketId().value(), receipt.fee().toDisplay());
}
}How to explain this in an interview
TicketView
Snapshot for UI. No close(). Changing view.status does nothing.
Ticket
close(), pay(), private status. Never a JSON bag.
Receipt
Domain value if it has Money. ReceiptView if the printer wants a barcode.
Service
Load, call ticket.close, save. Do not setStatus on a DTO.
In about a minute, say: “TicketView is a snapshot. Ticket.close owns the rules. I will not share one class for JSON, SQL, and close(). An untyped process(Object) method is the extreme DTO; park(Vehicle) is the domain API.”
Frozen values like Receipt can cross a boundary. Living entities cannot. If the kiosk holds Ticket, it can skip payment. Give it TicketView.
Fewer classes is not cheaper when one class changes for JSON, SQL, and close(). Split the snapshots.
Java: mapper owns JSON names
class TicketMapper {
TicketView toView(Ticket ticket) {
return new TicketView(
ticket.id().value(),
ticket.status().name(),
ticket.vehicle().plate().value());
}
}Takeaway
A DTO is a snapshot for a boundary. A domain object has rules. Sharing one class for JSON, SQL, and close() is how Jackson annotations leak into the lot. Two types is cheaper than one type with two jobs.