-
Couldn't load subscription status.
- Fork 28
Patterns
Common patterns that can be used when building Scala/Play services. These patterns may be able to provide the answer to some problems that you have been trying to solve.
UserAnswers is a data model, used typically by a SessionRepository, for storing all of the information a user has entered in their session. It uses a JsObject to allow an iterative approach to adding, removing or editing values in the Json with minimal rework to the structure of the overall data model. The SessionRepository is an implementation of a PlayMongoRepository from hmrc-mongo that allows you to interact with a mongoDB collection containing UserAnswers.
See examples of UserAnswers and SessionRepository.
The UserAnswers case class typically has the following functionality:
- An Index, or values from which an Index can be created, see example
-
data: JsObject = Json.obj()- a variable to store the data that a user has entered -
lastUpdated: Instant = Instant.now- a variable used for TTL -
get,setandremovefor editing the Json in thedatavariable - A companion object containing explicit reads, writes and format
While the SessionRepository typically has the following functionality:
- TTL and Index configuration, see examples
-
keepAlive- a method to update the lastUpdated field on a document -
get,setandclear- methods for interacting with the mongo collection
The standard usage pattern for working with a SessionRepository and UserAnswers is to store the UserAnswers data inside the request that is passed around to all controllers. The UserAnswers object is created at the point in which a user begins their journey:
for {
updatedAnswers <- Future.fromTry(
request.userAnswers.getOrElse(
UserAnswers(
userId = request.userId,
lastUpdated = Instant.now(clock)
)
).set(ExamplePage, value))
_ <- cc.sessionRepository.set(updatedAnswers)
} yield {...}and then is updated as part of the submission method from every controller that stores users answers data:
def onSubmit(): Action[AnyContent] = cc.authAndGetData.async {
implicit request =>
val form = ???
form.bindFromRequest().fold(
formWithErrors => ???,
value =>
for {
updatedAnswers <- Future.fromTry(
request.userAnswers.set(ExamplePage, value)
)
_ <- cc.sessionRepository.set(updatedAnswers)
} yield Redirect(...)
)
}Finally the UsersAnswers data should be removed from the SessionRepository both through TTL expiry, or whenever the data is no longer needed, such as successful submission of their journey:
for {
_ <- cc.sessionRepository.clear(request.userId)
} yield {...}It is also common to have a DataRequiredAction, this is added to controllers that require the userAnswers object to exist in the request already, before they can interact with it. This provides safety in the user trying to enter date when either there is a problem with that database or if their session has expired.
The Navigator and CheckMode patterns go hand-in-hand for managing routing around an application. This approach uses a single class for handling all routing logic, as opposed to having it directly in controllers.
The advantages of doing this are firstly that it helps to simplify controllers, by extracting out the sometimes-complex routing functionality. Also, it is easier to isolate and test the navigation behaviour, whilst not having to test additional logic from the controller - which is likely already handling multiple pieces of functionality. Finally it is easier to read and understand the journey and flow through the application when you can see it in one clear place, as opposed to having to open multiple controllers to find where they route.
CheckMode is used to facilitate the requirement of wanting to route from a page, when you have navigated to it from the CheckYourAnswers page. Often, if there is no related downstream information, the routing in CheckMode will simply take you back to the CheckYourAnswers page, this allows users to go back and edit a single value in the journey and return without the need to go through the entire remaining journey again.
One thing that must be considered is that this can become an anti-pattern very quickly, if used for large services with highly complex routing. You will end up with one huge file (and a bigger test file), with multiple branches and likely have high cyclomatic complexity. An alternative to a Navigator can be Page-based navigation (see below). Complex routing could be classified as having multiple loops or nested loops of data, which can build up in complexity as the user adds more data.
The Navigator typically has the following functionality:
- Normal and CheckMode routes map - functions that store the appropriate routing location for a given page
-
nextPage- the method called from all controller's submit methods. Taking aPage,ModeandUserAnswersand proxying through to the relevant routes map
The nextPage method will generally look like the below example. This takes all of the information required to work out routing for a specific page and then proxies through to the correct routeMap based on the mode.
def nextPage(page: Page, mode: Mode, userAnswers: UserAnswers): Call =
mode match {
case NormalMode =>
normalRoutes(page)(userAnswers)
case CheckMode =>
checkRouteMap(page)(userAnswers)
}The routeMap will then do one of two things, for simple routing it will return the relevant call based on the page in question. For more complex routing, that changes based on the UserAnswers, it will call to a private method to handle this logic.
private val normalRoutes: Page => UserAnswers => Call = {
case SimplePage => _ => routes.SimpleRoutingController.onPageLoad(NormalMode)
case ComplexRoutingPage => complexRoute
case _ => routes.JourneyRecoveryController.onPageLoad()
}
private def complexRoute(answers: UserAnswers): Call = {
(answers.get(ComplexRoutingPage)) match {
case Some(true) => routes.TrueComplexRouteController.onPageLoad(NormalMode)
case Some(false) => routes.FalseComplexRouteController.onPageLoad(NormalMode)
case _ => routes.JourneyRecoveryController.onPageLoad()
}
}Note above the reference to JourneyRecoveryController, this is often one way of dealing with problems that should logically never occur, but in some navigation scenarios they can occur if a user randomly navigates to URLs that don't follow the expected flow. The implementation of the recovery controller varies based on how valuable a team thinks it could be. This can either be used proactively to work out what the problem is and send the user down the correct journey. Or it can be used to clear out the session data and advise the user to start again.
It is also important to know that the Navigator will only be called if the form has validated successfully and we know that we definitely need to handle the routing. An example of this can be seen below.
def onSubmit(mode: Mode): Action[AnyContent] = cc.authAndGetData(period).async {
implicit request =>
form.bindFromRequest().fold(
formWithErrors =>
Future.successful(BadRequest(view(formWithErrors, mode))),
value =>
for {
updatedAnswers <- Future.fromTry(request.userAnswers.set(Page, value))
_ <- cc.sessionRepository.set(updatedAnswers)
} yield Redirect(Navigator.nextPage(Page, modem, updatedAnswers))
)
}
As mentioned above, Page-based navigation should be used for services that have more complex routing or looping/list style data. In this case, routing methods are created within the context of a Page and not a global Navigator.
The general usage pattern for this scenario is a simple trait named Page, that all PageObjects extend. This gives all pages a navigate method that can be called from a controller's submit method. The Page trait has navigateInNormalMode and navigateInCheckMode methods, by default these throw NotImplementedErrors to force the developer not to forget to implement them.
trait Page {
def navigate(mode: Mode, answers: UserAnswers): Call = mode match {
case NormalMode => navigateInNormalMode(answers)
case CheckMode => navigateInCheckMode(answers)
}
protected def navigateInNormalMode(answers: UserAnswers): Call =
throw new NotImplementedError("navigateInNormalMode is not implemented on this page")
protected def navigateInCheckMode(answers: UserAnswers): Call =
throw new NotImplementedError("navigateInCheckMode is not implemented on this page")
}The two navigation methods are then overridden on a Page-by-Page basis as per the following examples:
override protected def navigateInNormalMode(answers: UserAnswers): Call =
routes.SimpleRoutingController.onPageLoad(NormalMode, index)
override protected def navigateInCheckMode(answers: UserAnswers): Call =
routes.SimpleRoutingController.onPageLoad(CheckMode, index)
// ----------OR----------
override protected def navigateInNormalMode(answers: UserAnswers): Call =
answers.get(ComplexPage) match {
case Some(true) => routes.TrueComplexRouteController.onPageLoad(NormalMode)
case Some(false) => routes.FalseComplexRouteController.onPageLoad(NormalMode)
case _ => routes.JourneyRecoveryController.onPageLoad()
}
override protected def navigateInCheckMode(answers: UserAnswers): Call =
answers.get(ComplexPage) match {
case Some(true) => routes.TrueComplexRouteController.onPageLoad(CheckMode)
case Some(false) => routes.FalseComplexRouteController.onPageLoad(CheckMode)
case _ => routes.JourneyRecoveryController.onPageLoad()
} Property based testing focuses on the idea of testing a property of a piece of functionality - where a property is a high-level specification of behaviour that should hold true for a range of inputs. Instead of writing tests with manually created data inputs, a property-based test defines the range/type(s) of inputs it needs for which the property should always remain the same.
As per ScalaTest documentation, properties are specified as functions and the data points used to check properties which can be supplied by either tables or generators. Generator-driven property checks are the most common way of writing property based tests on MDTP - performed via use of the ScalaCheck library.
Within MDTP we don’t often write too many true ‘property’ based tests, but we do take advantage of the Gen library to help us generate large randomised samples of data. These samples can be super useful for testing things such as forms or data mappers - all of which can receive many different data inputs.
The most common way for writing a generator driven test is by using the Prop.forall method. Note: in the example below, no Generator is required as a parameter to the function as generators for Int are provided implicitly by Arbitrary.
forAll { (n: Int) =>
// Add assertion here
}We can however, do something even more useful such as creating our own generators for scenarios where we need more than the basic generators. In the below example we don't just want any arbitrary Int, but one below a certain value we can use suchThat to constrain the values:
def intsBelowValue(value: Int): Gen[Int] = {
arbitrary[Int] suchThat(_ < value)
}
forAll(intsBelowValue(10000) -> "intBelowMin") {
number: Int =>
// Add assertion here
}Or we could use methods such as choose and listOfN(there are many more) to provide even more specific values. The below example we create a string with a maximum length:
def stringsWithMaxLength(maxLength: Int): Gen[String] =
for {
length <- choose(1, maxLength)
chars <- listOfN(length, arbitrary[Char])
} yield chars.mkStringAnother common practice is generating data based on a model. To do this we need implicit model generators for all elements that make up the specific model, for which we can then create an overall Gen of the full model - see the following example.
Shrinking is the process a property based testing framework uses to find the minimum or simplest value required to cause a failure. Often when using generators the value that first failed can be fairly large or complex. Finding the initial failing case may have required tens or hundreds of attempts, and may contain irrelevant information. The framework will attempt to reduce that data set through shrinking. This is done by attempting to reduce the generators back towards their own zero point. For example, integers shrink towards 0, Lists shrink towards empty list etc...
Often this can make things hard to understand or debug as it is not always explicitly obvious to the developer what is happening. We can configure to disable shrinking using the below snippet in your test file.
implicit def dontShrink[A]: Shrink[A] = Shrink.shrinkAnyData management within a user journey is a critically important aspect to writing a web application suitable for MDTP and extra consideration should be taken as there are some scenarios where data can get a little bit inconsistent. The first common problem is through user behaviour and is related to dependent data.
Did your business make sales? Yes
-> What is the total value of those sales? £1000
---
Did your business make sales? No
Above is an example of dependent data, in this example there is the initial question about a business making sales. If the user says yes then we require more info. Where as if they say no, we don't. This can cause problems when we get to submission. Firstly, imagine a user has said yes to the first question then answered the second question, arriving at the Check Your Answers page. The user could go back and change their answer from yes to no and because of the way UserAnswers is structured, the answer to the second question would still exist in the model, but we would no longer want to submit that as it is no longer relevant. This brings in the need for the cleanUp method which should be called upon changing the answer, this would delete downstream data:
override def cleanup(value: Option[Boolean], userAnswers: UserAnswers): Try[UserAnswers] = {
value match {
case Some(false) => userAnswers.remove(SalesValuePage)
case _ => super.cleanup(value, userAnswers)
}
}The other way information can become inconsistent is if the user goes to edit their answer to the first question, but this time changes it from no to yes. When they arrive at the page for the second question (the dependant data) they could change their mind and click back a couple of times to arrive at the Check Your Answers page. But the answer to the first question is now yes instead of no and be don't have the second question that should also have been answered. For this reason, we should always validate our userAnswers data before submission, giving us a chance to correct anything in a graceful way and not having a generic exception thrown to the user. A common way of doing this on MDTP is through Cats Validated.
Cats Validated is a type that looks like an 'Either', which allows us to collect errors on the left and our valid output on the right. For more low-level information on how Validated works, see Michael Wolfendale's blog post on Data validation with cats. For the context of this documentation, we can see below some examples of how it can be used in a service fit for MDTP.
Did your business make sales? Yes
-> What is the total value of those sales? £1000
Does you business have a website? Yes
-> What is your website address? www.example-website.com
This scenario we have 2 questions, where if answered yes we would ask one more question for each. We want to be able to validate that if the user has said yes to either of the questions then the subsequent question should be answered. For this scenario we are using a ValidatedNec (NonEmptyChain), this guarantees at least one element will be present, this makes sense because if we do end up on the 'left' of the validated then we should logically have at least one error. In a case when there are multiple errors then we will get them all.
type ValidationResult[A] = ValidatedNec[ValidationError, A]
// ^ This is the same as Validated[NonEmptyChain[ValidationError], A]
def fromUserAnswers(answers: UserAnswers): ValidationResult[BusinessSalesRequest] =
(
validateBusinessSales(answers),
validateWebsite(answers)
).mapN(
(validBusinessSales, validWebsite) =>
BusinessSalesRequest(validBusinessSales, validWebsite)
)
private def validateBusinessSales(answers: UserAnswers): ValidationResult[Option[Double]] =
answers.get(BusinessSalesPage) match {
case Some(true) =>
answers.get(BusinessSalesAmountPage) match {
case None => DataMissingError(BusinessSalesAmountPage).invalidNec
case value => value.validNec
}
case Some(false) => None.validNec
case None => DataMissingError(SoldGoodsFromNiPage).invalidNec
}
private def validatewebsite(answers: UserAnswers) = ???This type of validation becomes very useful when we need to resolve the issues gracefully with the user. As we know all of the errors, we could use this information to redirect a user back down the specific part of the journey to fill-in or correct incorrect information. This is why this type of validation is done to check our submission model.
The pattern for checking a submission at the end of the journey is simply an extension of the pattern above. There is firstly a called to the fromUserAnswers method that returns our ValidationResult[A] which we can then match on to see if the submission should go ahead. If valid, then the users application can submit and if not the we can route the user to fill in any missing or incorrect information.
def onSubmit(): Action[AnyContent] = cc.authAndGetData(period).async {
implicit request =>
service.fromUserAnswers(request.userAnswers) match {
case Valid(data) => // Submission logic goes here
case Invalid(errors) => // Failure logic goes here, logging, JourneyRecovery etc...
}
}
You can also see the following example of the validation-before-submission pattern.
Twirl is notoriously bad for handling logic, it's hard to read and hard to test and for that reason it is best practice to keep as much logic as possible out of the the views and make that the responsibility of the controller.
Occasionally, it is necessary to have a complex view, that perhaps could require some intelligent logic, such as a 'Check Your Answers (CYA)' or dashboard-style home page and there are a few ways to deal with this. The first way is simply to have multiple views that could be rendered by the same controller under some different circumstances.
The other commonly used pattern is to create a view model, this is created within the controller and then passed as a parameter to the view. Keeping the logic in a much more appropriate place.
The most common example of this, which you get by default with the scaffolds, is summary lists for CYA pages.
def row(answers: UserAnswers)(implicit messages: Messages): Option[SummaryListRow] =
answers.get(HowManyEmployeesPage).map {
answer =>
SummaryListRowViewModel(
key = "howManyEmployees.checkYourAnswersLabel",
value = ValueViewModel(answer.toString),
actions = Seq(
ActionItemViewModel("site.change", routes.HowManyEmployeesController.onPageLoad(CheckMode).url)
.withVisuallyHiddenText(messages("howManyEmployees.change.hidden"))
)
)
}CYA (or any summary pages) can get long, so this way means that you can build up a SummaryListRow for each individual data item. Which then can be added to a list and passed to the view, creating a clean and modular build up information as opposed to a large nested structure inside your view.