In CRUD REST services it often happens that the data which can be requested or saved or updated in not always the same. It can depend on various aspects like the role of the user or the state of the object in question.
Let’s see how this can be solved declaratively using jTransfo. The actual services use transfer objects. While this introduced a step to copy the fields (which would not exist in the case of detached objects) this explicitly allows control over the data to copy, possibly changing the representation. Additionally this shields your services from changes in your domain model which allows you to keep your API stable.
For this example, let’s look at a proposal. This contains estimates of amounts (and unit price) of goods which are needed to do a particular job. When the proposal has been accepted by the customer, the actual amounts can be set on the estimates. This results in the following domain model.
A proposal can have a couple of states:
- created: the proposal has not yet been approved. Estimates can be added but no executions.
- approved: executions can be added, the estimated counts cannot be changed (adding new estimates with product link and no (zero) count is possible).
- finished: no more changes allowed.
The checking when fields can be updated are handled declaratively in the domain objects, so the logic for the create, get and update methods (skipping validation) are quite lean.
@Autowired private JTransfo jTransfo; @Autowired private proposalDao proposalDao; @Transactional public void create(ProposalTo proposalTo) { Proposal proposal = jTransfo.convertTo(proposalTo, ProposalTo.class, "CREATE"); proposalDao.save(proposal); } @Transactional public void update(ProposalTo proposalTo) { Proposal proposal = proposalDao.get(proposalTo.getId()); proposal = jTransfo.convert(proposalTo, proposal, proposal.getState()); proposalDao.update(proposal); } @Transactional(readOnly = true) public ProposalTo get(long proposalId) { Proposal proposal = proposalDao.get(proposalId); return jTransfo.convertTo(proposal, ProposalTo.class); } |
The magic is in the tags passed (last parameter) to the jTransfo convert methods in save and update methods combined with annotations in the transfer object definitions. For creating “CREATE” is passed and the object state is used when the object already existed. The methods to change the state of the object are not shown here.
In the transfer objects, MapOnly annotations are used to indicate when (based on the tags) fields can be copied. A field without MapOnly annotation is always copied (unless it is marked with @NotMapped). When a MapOnly annotation exists, it is only copied when the convert contains a tag which is mentioned in the annotations. Defining several MapOnly annotations is possible by grouping inside a MapOnlies annotation. For fallback, “*” matches all tags.
The transfer object definitions use lombok to generate getters and setters.
@Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @DomainClass("pkg.Proposal") public class ProposalTo extends AbstractChangeLoggedIdentifiedTo { @MapOnlies({ @MapOnly(value = { "CREATE" }), @MapOnly(value = "*", readOnly = true) }) private String description; @MappedBy(readOnly = true) private String state; // "CREATED", "APPROVED", "FINISHED" @MapOnlies({ @MapOnly(value = { "CREATE", "CREATED", "APPROVED" }), @MapOnly(value = "*", readOnly = true) }) @MappedBy(typeConverter = "estimateToList") private List<EstimateTo> estimates = new ArrayList<EstimateTo>(); } |
- Description can only be set when creating.
- State is read-only.
- Estimates can be changed as long as the object is not in finished state. Contents controled below.
@Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @DomainClass("pkg.Estimate") public class EstimateTo extends AbstractIdentifiedTo { @MapOnlies({ @MapOnly(value = { "CREATE", "CREATED" }), @MapOnly(value = "APPROVED", typeConverter = "newReadOnlyDomain" ), @MapOnly(value = "*", readOnly = true) }) @MappedBy(typeConverter = "readOnlyDomain") private ProductTo product; @MapOnlies({ @MapOnly(value = { "CREATE", "CREATED" }), @MapOnly(value = "*", readOnly = true) }) private Double estimatedCount; @MapOnlies({ @MapOnly(value = { "APPROVED" }), @MapOnly(value = "*", readOnly = true) }) @MappedBy(typeConverter = "executionToList") private List<ExecutionTo> uitvoeringen = new ArrayList<ExecutionTo>(); } |
- The product (link, the readOnlyDomain type converter does not recurse to updating the product fields) can be set when the proposal is not approved. Once the proposal is approved, the “newReadOnlyDomain” converter assures that the field can only be set when it was null, allowing the product to be set on new estimate objects.
- Estimated count can only be set as long as the proposal is not approved.
- Executions can only be set if the proposal is approved (but not finished).
For completeness, the product and execution transfer objects are also displayed. The product records are not updated as this used the “readOnlyDomain” type converter. The execution has no restrictions.
@Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @DomainClass("pkg.Product") public class ProductTo extends AbstractIdentifiedTo { private String description; private Double unitPrice; } |
@Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @DomainClass("pkg.Execution") public class ExecutionTo extends AbstractIdentifiedTo { private double count; private Date date; } |
Leave a Reply