Skip to main content
ElastiCORE 3.0 is a release candidate: 3.0.261022-rc1 on Maven Central. Projects on the 2.2 line: see the 2.2 documentation.
Version: 3.0

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​

WriteMeans
@rootthe 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:

FileKindHolds
SalesOrderBaseregenerated (@MappedSuperclass, abstract)the fields, event methods, collection and invariant plumbing
SalesOrdergenerate-once (@Entity, extends SalesOrderBase)the business methods you write
SalesOrder.java (yours)
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();
}
}
FieldSetterCreate (POST)Update (PUT)Changed by
default (locked)protectedtakensame value passes, a different one 422the root's methods
@behaviorprotectednot taken (READ_ONLY)not takenthe root's methods
@readonly / @noinputprotected (public on an @editable root)not takennot takenserver code: creation() on a new root, then the root's methods
@editablepublictakentakensetter, 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
generated
@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​

DSLGeneratesReference
@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 fieldthe same check at save time, besides the DTO's 400Fields
@factory(place)OrderFactory.place(...), generate-once stubEntities
@specification(highValue, totalAmount > 100)OrderSpecifications.highValue() - a Predicate<Order>Entities
@event(Placed(total), Shipped)OrderPlacedEvent, raisePlaced(), published when savedDerived values and events
states:ship(), canShip(), 409 for a refused stepLifecycle
commands:POST /{id}/<command> down to an abstract method on the baseLifecycle
@snapshot, @computederive(lookup), called before every saveDerived values and events
domainService:, processManager:a regenerated XxxBase with the ports + a generate-once class of yoursDerived 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​

env.yml - the DDD layered preset
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: true in env.yml records events in an outbox table in the same transaction and relays them after commit to an OutboxPublisher bean of yours.
  • mapping: xml takes the JPA annotations off the domain classes and writes the same mapping to META-INF/orm.xml; archTest: true generates 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.