Adding further business logic - Worked Examples

Let’s remind ourselves of the original use cases we identified; some of these have been implemented already (admittedly, not all with tests around them):

  • create an Owner : yes, implemented

  • add and remove Pets for said Owner : yes, implemented.

  • book a Pet in for a Visit: yes, implemented.

  • enter an outcome and cost of a Visit: not yet

  • allow an Owner to pay for a Visit: not yet

  • find Visits not yet paid and overdue (more than 28 days old): not yet

  • delete an Owner and its Pets and Visits, so long as there are no unpaid Visits: partly. We currently just delete everything.

In this section we’ll implement the missing functionality, along with unit or integration tests as necessary.

Enter an outcome

An outcome for a Visit consists of a diagnosis, and also the cost to be paid by the Pet's Owner.

Visit enterOutcome

Solution

git checkout tags/330-enter-an-outcome
mvn clean package jetty:run

Exercise

  • add a new integration test, Visit_enterOutcome_IntegTest,

    public class Visit_enterOutcome_IntegTest extends PetClinicModuleIntegTestAbstract {
    
        Visit visit;
    
        @Before
        public void setup() {
            // given
            Owner owner = runBuilderScript(Owner_enum.JOHN_SMITH);
            Pet pet = owner.getPets().first();
            visit = wrap(mixin(Pet_visits.class, pet)).coll().iterator().next();
        }
    
        @Test
        public void happy_case() {
    
            // when
            String diagnosis = someRandomDiagnosis();
            BigDecimal cost = someRandomCost();
    
            wrap(visit).enterOutcome(diagnosis, cost);
    
            // then
            assertThat(visit.getDiagnosis()).isEqualTo(diagnosis);
            assertThat(visit.getCost()).isEqualTo(cost);
        }
    
        private BigDecimal someRandomCost() {
            return new BigDecimal(20.00 + fakeDataService.doubles().upTo(30.00d));
        }
    
        private String someRandomDiagnosis() {
            return fakeDataService.lorem().paragraph(3);
        }
    
        @Inject
        FakeDataService fakeDataService;
    }
  • in Visit, add in the two new properties and action.

    @Action(semantics = SemanticsOf.IDEMPOTENT)
    public Visit enterOutcome(
            @Parameter(maxLength = 4000)
            @ParameterLayout(multiLine = 5)
            final String diagnosis,
            final BigDecimal cost) {
        this.diagnosis = diagnosis;
        this.cost = cost;
        return this;
    }
    
    @javax.jdo.annotations.Column(allowsNull = "true", length = 4000)
    @Property(editing = Editing.DISABLED, editingDisabledReason = "Use 'enter outcome' action")
    @PropertyLayout(multiLine = 5)
    @Getter @Setter
    private String diagnosis;
    
    @javax.jdo.annotations.Column(allowsNull = "true", length = 6, scale = 2)
    @Property(editing = Editing.DISABLED, editingDisabledReason = "Use 'enter outcome' action")
    @Getter @Setter
    private BigDecimal cost;
  • update Visit.layout.xml for the two new properties and action.

  • add in some further integration tests to ensure that the properties cannot be edited directly:

    @Test
    public void cannot_edit_outcome_directly() {
    
        // expecting
        expectedExceptions.expect(DisabledException.class);
        expectedExceptions.expectMessage("Use 'enter outcome' action");
    
        // when
        String diagnosis = someRandomDiagnosis();
        wrap(visit).setDiagnosis(diagnosis);
    }
    
    @Test
    public void cannot_edit_cost_directly() {
    
        // expecting
        expectedExceptions.expect(DisabledException.class);
        expectedExceptions.expectMessage("Use 'enter outcome' action");
    
        // when
        BigDecimal cost = someRandomCost();
    
        wrap(visit).setCost(cost);
    }

Pay for a visit

We’ll support this use case through a new action "paid", on the Visit domain entity.

To support the testing (and with half an eye to a future use case) we’ll also implement a "findNotPaid" query on the Visits repository domain service.

Solution

git checkout tags/340-pay-for-a-visit
mvn clean package jetty:run

Exercise

Let’s first work on the happy case:

  • Update Visit with a new paid() action and paidOn property. Also inject ClockService:

    @Action(semantics = SemanticsOf.IDEMPOTENT)
    public Visit paid() {
        paidOn = clockService.now();
        return this;
    }
    
    @javax.jdo.annotations.Column(allowsNull = "true")
    @Property(editing = Editing.DISABLED, editingDisabledReason = "Use 'paid' action")
    @Getter @Setter
    private LocalDate paidOn;
    
    ...
    
    @Inject
    ClockService clockService;
  • Update the Visits domain service repository to find Visits that haven’t been paid:

    @Programmatic
    public java.util.List<Visit> findNotPaid() {
        TypesafeQuery<Visit> q = isisJdoSupport.newTypesafeQuery(Visit.class);
        final QVisit cand = QVisit.candidate();
        q = q.filter(
                cand.paidOn.eq(q.parameter("paidOn", LocalDateTime.class)
            )
        );
        return q.setParameter("paidOn", null)
                .executeList();
    }
  • Extend OwnerBuilderScript so that all but the last Visit for each Owner's Pets has been paid.

    Add some further supporting methods:

    private String someDiagnosis() {
        return fakeDataService.lorem().paragraph(fakeDataService.ints().between(1, 3));
    }
    
    private BigDecimal someCost() {
        return new BigDecimal(20.00 + fakeDataService.doubles().upTo(30.00d));
    }

    In the execute(…​), update the for loop so that all Visits have an outcome and all but the last (for each Owner) has been paid:

    for (int i = 0; i < petDatum.numberOfVisits; i++) {
        ...
        LocalDateTime someTimeInPast = ...
        Visit visit = ...
        wrap(visit).enterOutcome(someDiagnosis(), someCost());
        if(i != petDatum.numberOfVisits - 1) {
            setTimeTo(ec, someTimeInPast.plusDays(fakeDataService.ints().between(10,30)));
            wrap(visit).paid();
        }
    }

Prevent payment for a visit twice

We’ve already seen that it’s possible to validate arguments to actions; for example that a Visit can only be booked in the future. But if a Visit has already been paid for, then we don’t want the user to be able to even attempt to invoke the action.

The framework provides three different types of pre-condition checks:

  • "See it?" - should the action/property be visible at all, or has it been hidden?

  • "Use it" - if visible, then can the action/property be used or has it been disabled (greyed out)

  • "Do it" - if the action/property is ok to be used (action invoked/property edited) then are the proposed action arguments or new property value valid, or are they invalid?

Or in other words, "see it, use it, do it".

As with validation, disablement can be defined either declaratively (annotations) or imperatively (supporting methods). Let’s see how an imperative supporting method can be used to implement this particular requirement (that a visit can’t be paid for twice).

Solution

git checkout tags/350-prevent-payment-for-a-visit-twice
mvn clean package jetty:run

Exercise

  • update Visit_pay_IntegTest to ensure cannot enter into the paidOn property directly:

    @Test
    public void cannot_edit_paidOn_directly() {
    
        // expecting
        expectedExceptions.expect(DisabledException.class);
        expectedExceptions.expectMessage("Use 'paid on' action");
    
        // when
        wrap(visit).setPaidOn(clockService.now());
    }
  • now, add in the test that asserts that a Visit cannot be paid more than once:

    @Test
    public void cannot_pay_more_than_once() {
    
        // given
        wrap(visit).paid();
        assertThat(visits.findNotPaid()).asList().doesNotContain(visit);
    
        // expecting
        expectedExceptions.expect(DisabledException.class);
        expectedExceptions.expectMessage("Already paid");
    
        // when
        wrap(visit).paid();
    }
  • and finally update Visit. This is done using a supporting method.

    public String disablePaid() {
        return getPaidOn() != null ? "Already paid": null;
    }

Find Visits not yet paid and overdue

In the previous scenario we implemented Visits#findNotPaid(). Since this is pretty important information, let’s surface that to the end-user by adding it to the home page dashboard.

We could also go a little further by allowing the user to use the dashboard to update visits that have been paid. This is a good example of how a view model can support specific business processes, in this case saving the end-user from having to navigate down to each and every one of the Visits.

Solution

git checkout tags/360-find-visits-not-yet-paid-and-overdue
mvn clean package jetty:run
Dashboard overdue

Exercise

  • update Dashboard:

    @CollectionLayout(defaultView = "table")
    public List<Visit> getOverdue() {
        List<Visit> notPaid = visits.findNotPaid();
        LocalDateTime thirtyDaysAgo = clockService.nowAsLocalDateTime().minusDays(30);
        return notPaid.stream()
                .filter(x -> x.getVisitAt().isBefore(thirtyDaysAgo))        (1)
                .collect(Collectors.toList());
    }
    
    @Action(semantics = SemanticsOf.IDEMPOTENT, associateWith = "overdue")  (2)
    public Dashboard paid(List<Visit> visits) {
        for (Visit visit : visits) {
            if(visit.getPaidOn() == null) {
                visit.paid();
            }
        }
        return this;
    }
    
    @javax.inject.Inject
    Visits visits;
    
    @javax.inject.Inject
    ClockService clockService;
    1 An alternative (better?) design would have been to add a new query method in Visits to find those overdue, avoiding the client-side filtering that we see above.
    2 The "associateWith" annotation results in checkboxes alongside the "overdue" collection, with the collection providing the set of values for the parameter.
  • update Dashboard.layout.xml also

  • write a new Dashboard_paid_IntegTest integration test:

    public class Dashboard_paid_IntegTest extends PetClinicModuleIntegTestAbstract {
    
        Dashboard dashboard;
    
        @Before
        public void setup() {
            // given
            runFixtureScript(new PersonaEnumPersistAll<>(Owner_enum.class));
            dashboard = homePageProvider.dashboard();
        }
    
        @Test
        public void happy_case() {
    
            // given
            List<Visit> overdue = dashboard.getOverdue();
            assertThat(overdue).isNotEmpty();
    
            // when
            wrap(dashboard).paid(overdue);
    
            // then
            List<Visit> overdueAfter = dashboard.getOverdue();
            assertThat(overdueAfter).isEmpty();
    
            for (Visit visit : overdue) {
                assertThat(visit.getDiagnosis()).isNotNull();
                assertThat(visit.getPaidOn()).isNotNull();
            }
        }
    
        @Inject
        HomePageProvider homePageProvider;
    }
  • Running the integration test at this point will produce a null pointer exception. That’s because the framework has had no opportunity to inject any domain services into the Dashboard.

    Under normal runtime cases this doesn’t matter because the only caller of the method is the framework itself, and when the domain object is rendered the framework will automatically ensure that any domain sevices are injected.

    In an integration test this doesn’t occur, and so we need to manually inject the services. It makes most sense to do this in HomePageProvider; we use the framework-provided ServiceRegistry2 domain service:

    @HomePage
    public Dashboard dashboard() {
        return serviceRegistry2.injectServicesInto(new Dashboard());
    }
    @Inject
    ServiceRegistry2 serviceRegistry2;

Digression: Hiding Columns in Tables

We could improve the dashboard a little. After all, in the "overdue" collection there’s no point in showing the "paidOn"; the value will always be null. Also, the "reason" column is also somewhat superfluous (as, arguably, is the "diagnosis" column):

Dashboard overdue ui hints

The framework offers two different ways to address this, so we’ll show both.

Solution

git checkout tags/370-digression-hiding-columns-in-tables
mvn clean package jetty:run

Exercise

  • The first technique is within the Java code; one could think of this as an implication within the "application layer".

    We use a domain service that implements TableColumnOrderService as an SPI to "advise" the framework on how to render the collection. Traditionally such classes are implemented as a nested static class, in this case of Dashboard:

    @DomainService(nature = NatureOfService.DOMAIN)
    public static class RemovePaidOnFromOverdue extends TableColumnOrderService.Default {
        @Override
        public List<String> orderParented(
                final Object parent,
                final String collectionId,
                final Class<?> collectionType,
                final List<String> propertyIds) {
            if (parent instanceof Dashboard && "overdue".equalsIgnoreCase(collectionId)) {
                propertyIds.remove("paidOn");
            }
            return propertyIds;
        }
    }

    The above code removes the "paidOn" column.

  • The second technique is to exploit the fact that the HTML generated by the framework is liberally annotated with domain class identifiers. The column can therefore be removed by supplying the appropriate CSS. We could think of this as an implementation within the presentation layer.

    In the src/main/webapp/css/application.css file, add:

    .domainapp-modules-impl-dashboard-Dashboard .entityCollection .overdue .Visit-reason {
        display: none;
    }

Another Digression: Icons and CSS

In the same way that titles can be specified imperatively, so too can icons, using the iconName() method. One use case is for a domain object that has several states: the iconName() defines a suffix which is used to lookup different icons (eg "ToDoItem-notDone.png" and "ToDoItem-done.png").

Similarly, it’s possible to specify CSS hints imperatively using the cssClass(). This returns a simple string that is added as a CSS class wherever the object is rendered in the UI.

In this exercise we’ll use a different icon for the various species of Pet:

Pet icons

Let’s also use a strike-through text for all Visits that are paid when rendered within a collection:

Visits paid strikethrough

Solution

git checkout tags/380-another-digression-icons-and-css
mvn clean package jetty:run

Exercise

For the icons:

  • add new icons for each of the pet species: Pet-dog.png, Pet-cat.png, Pet-hamster.dog and Pet-budgerigar.png

  • add an iconName() method to Pet:

    public String iconName() {
        return getPetSpecies().name().toLowerCase();
    }

For the CSS class:

  • add a cssClass() method to Visit:

    public String cssClass() {
        boolean isPaid = getPaidOn() != null;
        return isPaid ? "paid": null;
    }
  • update application.css:

.entityCollection .domainapp-modules-impl-visits-dom-Visit .paid {
    text-decoration: line-through;
    color: lightgrey;
}

Delete an Owner provided no unpaid Visits

Solution

git checkout tags/390-delete-an-owner-provided-no-unpaid-visits
mvn clean package jetty:run

Exercise

We don’t want Owner (in the pets module) to check for unpaid Visits, because that would create a cyclic dependency between modules. Instead, we’ll use a subscriber in the visits module which can veto any attempt to delete an owner if there are unpaid visits.

For this, we arrange for the Owner to emit an action domain event when its delete() action is invoked. In fact, the event will be emitted by the framework up to five times: to check if the action is visible, if it is disabled, if it’s valid, pre-execute and post-execute. The subscriber in the visits module will therefore potentially veto on the disable phase.

  • in the Visits repository, add findNotPaidBy method to find any unpaid Visits for an Owner:

    @Programmatic
    public java.util.List<Visit> findNotPaidBy(Owner owner) {
        TypesafeQuery<Visit> q = isisJdoSupport.newTypesafeQuery(Visit.class);
        final QVisit cand = QVisit.candidate();
        q = q.filter(
                cand.paidOn.eq(q.parameter("paidOn", LocalDateTime.class)
            ).and(
                    cand.pet.owner.eq(q.parameter("owner", Owner.class))
                )
        );
        return q.setParameter("paidOn", null)
                .setParameter("owner", owner)
                .executeList();
    }
  • update Owner’s `delete() action so that it emits an action domain event.

    import org.apache.isis.applib.services.eventbus.ActionDomainEvent;
    ...
    public static class Delete extends ActionDomainEvent<Owner> {}  (1)
    @Action(
            domainEvent = Delete.class                              (2)
            semantics = SemanticsOf.NON_IDEMPOTENT                  (3)
    )
    public void delete() {
        final String title = titleService.titleOf(this);
        messageService.informUser(String.format("'%s' deleted", title));
        repositoryService.removeAndFlush(this);
    }
    1 declare the event, and
    2 emit it
    3 change from NON_IDEMPOTENT_ARE_YOU_SURE (due to a bug in the framework).
  • add a new integration test:

    public class Owner_delete_IntegTest extends PetClinicModuleIntegTestAbstract {
    
        @Test
        public void can_delete_if_there_are_no_unpaid_visits() {
    
            // given
            runFixtureScript(Owner_enum.FRED_HUGHES.builder());
    
            Owner owner = Owner_enum.FRED_HUGHES.findUsing(serviceRegistry);
            List<Visit> any = visits.findNotPaidBy(owner);
            assertThat(any).isEmpty();
    
            // when
            wrap(owner).delete();
    
            // then
            Owner ownerAfter = Owner_enum.FRED_HUGHES.findUsing(serviceRegistry);
            assertThat(ownerAfter).isNull();
        }
    
        @Test
        public void cannot_delete_with_unpaid_visits() {
    
            // given
            runFixtureScript(Owner_enum.MARY_JONES.builder());
    
            Owner owner = Owner_enum.MARY_JONES.findUsing(serviceRegistry);
            List<Visit> any = visits.findNotPaidBy(owner);
            assertThat(any).isNotEmpty();
    
            // expect
            expectedExceptions.expect(DisabledException.class);
            expectedExceptions.expectMessage("This owner still has unpaid visit(s)");
    
            // when
            wrap(owner).delete();
        }
    
        @Inject
        Visits visits;
    }
  • add the subscriber to veto the action if required:

    @DomainService(nature = NatureOfService.DOMAIN)
    public class VetoDeleteOfOwnerWithUnpaidVisits
            extends org.apache.isis.applib.AbstractSubscriber {
    
        @org.axonframework.eventhandling.annotation.EventHandler
        public void on(Owner.Delete ev) {
    
            switch (ev.getEventPhase()) {
            case DISABLE:
                Collection<Visit> visitsForPet = visits.findNotPaidBy(ev.getSource());
                if (!visitsForPet.isEmpty()) {
                    ev.veto("This owner still has unpaid visit(s)");
                }
                break;
            }
        }
    
        @javax.inject.Inject
        Visits visits;
    }
  • finally, in PetClinicModuleIntegTestAbstract, we need to make a small adjustment to use the same event bus implementation as the production app:

    super(new PetClinicModule()
        .withAdditionalServices(DeploymentCategoryProviderForTesting.class)
        .withConfigurationProperty("isis.services.eventbus.implementation","axon")      (1)
        .withConfigurationProperty(TranslationServicePo.KEY_PO_MODE, "write")
    );
    1 specify Axon as the event bus implementation