DDD (ddd-springboot)
architecture = 'ddd' reads the model as a domain model: aggregates with a root, members reached
through the root, value objects, references to other aggregates by id, rules the root keeps, and
events it raises.
Aggregate boundaries
| Write | Means |
|---|---|
@root | the entity is an aggregate root |
@aggregate(Order) | the entity is a member of the Order aggregate |
| (nothing) | a member of a root that holds it in a List<X> @OneToMany(...) (or @cascade), otherwise a root of its own |
Members get no repository, service, controller or service test - an aggregate is loaded and
saved through its root. @root is needed only on an entity that another root lists but that is a
root itself (Customer.orders: List<Order>).
Writes to members go through the root: the getter returns a read-only view
(order.getItems().add(x) throws UnsupportedOperationException); use the generated
addOrderItem / removeOrderItem / replaceItems.
A root is sealed by default - @editable opens it
A root's fields are locked: a request gives them a value when the root is made, and after that the root's methods change them. The root is split in two files:
| File | Kind | Holds |
|---|---|---|
SalesOrderBase | regenerated (@MappedSuperclass, abstract) | the fields, event methods, collection and invariant plumbing |
SalesOrder | generate-once (@Entity, extends SalesOrderBase) | the business methods you write |
public class SalesOrder extends SalesOrderBase {
public void confirm() {
if (getStatus() != OrderStatus.DRAFT) throw new InvariantViolationException("only a draft is confirmed");
setStatus(OrderStatus.CONFIRMED); // protected setter
raiseConfirmed();
}
}
| Field | Setter | Create (POST) | Update (PUT) | Changed by |
|---|---|---|---|---|
| default (locked) | protected | taken | same value passes, a different one 422 | the root's methods |
@behavior | protected | not taken (READ_ONLY) | not taken | the root's methods |
@readonly / @noinput | protected (public on an @editable root) | not taken | not taken | server code: creation() on a new root, then the root's methods |
@editable | public | taken | taken | setter, PUT |
@editable on the meta: line makes the whole root plain data - reference data such as items,
partners or codes. A root without any @editable field has no PUT at all: create, read, delete
and its commands.
Code that creates a root uses the same path as the mapper: order.creation().setOrderNo("SO-1")
(Kotlin: order.creation().orderNo = "SO-1").
References to other aggregates by id
SalesOrder:
fields:
partner: Partner @ManyToOne # written as usual
@Column(name = "partner_partner_id")
@Setter(AccessLevel.PROTECTED) // locked - set when made, then by methods
private Long partnerId; // the reference is this field
/** Another aggregate, to read. The reference is partnerId; ... */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "partner_partner_id", insertable = false, updatable = false)
@Setter(AccessLevel.NONE)
private Partner partner; // a read-only join on the same column
The table does not change; the aggregate holds only the other root's identifier. A save request
sends partnerId; a required reference (member: Member!) makes it required in the DTO. A
partnerId that names no row answers 404 Partner 99 does not exist (code
reference.SalesOrder.partner), checked before saving. To keep an object reference, write
@objectref. referenceColumn: short in env.yml names the column partner_id for a new project.
Domain rules on the root
| DSL | Generates | Reference |
|---|---|---|
@invariant(items.size > 0, 'an order has at least one line') | OrderInvariants listener, checked when the row is written (422) | Entities |
@min, @minsize on a root's field | the same check at save time, besides the DTO's 400 | Fields |
@factory(place) | OrderFactory.place(...), generate-once stub | Entities |
@specification(highValue, totalAmount > 100) | OrderSpecifications.highValue() - a Predicate<Order> | Entities |
@event(Placed(total), Shipped) | OrderPlacedEvent, raisePlaced(), published when saved | Derived values and events |
states: | ship(), canShip(), 409 for a refused step | Lifecycle |
commands: | POST /{id}/<command> down to an abstract method on the base | Lifecycle |
@snapshot, @compute | derive(lookup), called before every save | Derived values and events |
domainService:, processManager: | a regenerated XxxBase with the ports + a generate-once class of yours | Derived values and events |
Repository ports
The domain owns a narrow interface; the Spring Data implementation lives in infrastructure. Search
and paging go through an application-layer query port, so Pageable and Specification stay out of
the domain:
// domain.repository - no Spring types
public interface OrderRepositoryPort {
Optional<Order> findById(String orderId);
Order save(Order order);
void delete(Order order);
boolean existsById(String orderId);
}
// application.port.out - search and paging
public interface OrderQueryPort {
List<Order> findAll(Specification<Order> spec);
Page<Order> findAll(Specification<Order> spec, Pageable pageable);
long count(Specification<Order> spec);
}
// infrastructure.persistence - one adapter implements both
public class OrderRepositoryAdapter implements OrderRepositoryPort, OrderQueryPort { ... }
The application service receives the two ports and an EntityManager (used only to turn ids into
references).
Package layout
namespace:
entity: com.acme.shop.domain.model
enumeration: com.acme.shop.domain.model
event: com.acme.shop.domain.event
domainService: com.acme.shop.domain.service
domainRepository: com.acme.shop.domain.repository
service: com.acme.shop.application
dto: com.acme.shop.application.dto
queryPort: com.acme.shop.application.port.out
port: com.acme.shop.application.port.out
control: com.acme.shop.interfaces.rest
repository: com.acme.shop.infrastructure.persistence
persistence: com.acme.shop.infrastructure.persistence
Keep domainService apart from service: a domain service is the domain's, the application
service sits above it.
Options
outbox: trueinenv.ymlrecords events in an outbox table in the same transaction and relays them after commit to anOutboxPublisherbean of yours.mapping: xmltakes the JPA annotations off the domain classes and writes the same mapping toMETA-INF/orm.xml;archTest: truegenerates ArchUnit tests of the layer boundaries.
Sample: elasticore-template-ddd
(catalog, shop, billing - with a process manager across contexts). It is built with
architecture = 'hexagonal', which includes everything on this page.