Automating template management

with Scala 3 and Iron

Magda Stożek

Making illegal states unrepresentable

Context

  • SMS, email, push notifications
  • other teams
  • templates

Problem

  • complexity
  • long process
  • manual steps
  • unclear ownership
  • errors

Our dream

  • Correctness
  • Short feedback loop
  • Simplicity
  • Clarity
  • Responsibility
  • Automation

Template Service

🐉

Scala 3

SMS

text: "Hello %name%, your code is %code%"
params:
  - name
  - code
subject: 'Booking completed'
external_id: '100122055500'
params:
  - name
  - date

Email

heading: 'New discount'
text: 'Check your discounts tab'
link: 'acme://discounts'
category: 'offers'

Push

owner: '@booking-team'
---
subject: 'Booking completed'
external_id: '100122055500'
params:
  - name
  - amount

Email

owner: '@offers-team'
---
heading: 'New discount'
text: 'Check your discounts tab'
link: 'acme://discounts'
category: 'offers'

Push

SMS

owner: '@auth-team'
users:
  - '@booking-team'
  - '@reservations'
reference:  'https://acme.org/XYZ-1234'
---
text: "Hello %name%, your code is %code%"
params:
  - name
  - code
case class PushTemplate(
  heading: String,
  text: String,
  link: Option[String],
  category: Option[String],
  params: Option[List[String]],
  owner: String,
  users: Option[List[String]]
  reference: Option[String]
)

Version #0 - plain types

def parseFile(fileContent: String): Either[io.circe.Error, PushTemplate] = 
	io.circe.parser.decode[PushTemplate](fileContent)
case class PushTemplate(
  heading: String,
  text: String,
  link: Option[String],
  category: Option[String],
  params: Option[List[String]],
  owner: String,
  users: Option[List[String]]
  reference: Option[String]
)

Version #0 - plain types

heading: 'New discount'
text: 'Check your discounts tab'
owner: '@auth-team'

🙂

def parseFile(fileContent: String): Either[io.circe.Error, PushTemplate] = 
	io.circe.parser.decode[PushTemplate](fileContent)
case class PushTemplate(
  heading: String,
  text: String,
  link: Option[String],
  category: Option[String],
  params: Option[List[String]],
  owner: String,
  users: Option[List[String]]
  reference: Option[String]
)

Version #0 - plain types

text: 'Check your discounts tab'
owner: '@billing-team'

🙂

def parseFile(fileContent: String): Either[io.circe.Error, PushTemplate] = 
	io.circe.parser.decode[PushTemplate](fileContent)

Improvement #1 - heading

case class PushTemplate(
  //...
  heading: String,
  //...
)
heading: ''
text: 'Check your discounts tab'
owner: '@billing-team'

😕

Improvement #1 - heading

case class PushTemplate(
  //...
  heading: String :| Not[Blank],
  //...
)
def parseFile(fileContent: String): Either[io.circe.Error, PushTemplate] = 
	io.circe.parser.decode[PushTemplate](fileContent)
heading: ''
text: 'Check your discounts tab'
owner: '@billing-team'

🙂

Improvement #2 - url

case class PushTemplate(
  //...
  reference: Option[String],
  //...
)

😕

heading: 'New discount'
text: 'Check your discounts tab'
owner: '@billing-team'
reference: 'TBD'
case class PushTemplate(
  //...
  reference: Option[String],
  //...
)

Improvement #2 - url

type Url = String :| ValidURL
case class PushTemplate(
  //...
  reference: Option[String],
  //...
)

🙂

heading: 'New discount'
text: 'Check your discounts tab'
owner: '@billing-team'
reference: 'TBD'
case class PushTemplate(
  //...
  reference: Option[Url],
  //...
)
//https://github.com/Iltotore/iron/blob/main/main/src/io/github/iltotore/iron/constraint/string.scala
type ValidURL =
  Match[
    "((\\w+:)+\\/\\/)?(([-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6})|(localhost))(:\\d{1,5})?(\\/|\\/([-a-zA-Z0-9@:%_\\+.~#?&//=]*))?"
  ] DescribedAs "Should be a URL"

Improvement #3 - deep link

case class PushTemplate(
  //...
  reference: Option[String],
  //...
)

😕

heading: 'New discount'
text: 'Check your discounts tab'
owner: '@billing-team'
link: 'acme://statement'
case class PushTemplate(
  //...
  link: Option[Url],
  //...
)

Improvement #3 - deep link

case class PushTemplate(
  //...
  reference: Option[String],
  //...
)

🙂

heading: 'New discount'
text: 'Check your discounts tab'
owner: '@billing-team'
link: 'acme://statement'
case class PushTemplate(
  //...
  link: Option[Link],
  //...
)
type Link = String :| DescribedAs[
  ValidURL | Match["^acme://.*$"],
  "Should be a valid deep link or URL"
]

Improvement #4 - categories

case class PushTemplate(
  //...
  reference: Option[String],
  //...
)

😕

heading: 'New discount'
text: 'Check your discounts tab'
owner: '@billing-team'
category: 'default'
case class PushTemplate(
  //...
  category: Option[String],
  //...
)
val PossibleCategories = Seq("general", "offers")

Improvement #4 - categories

case class PushTemplate(
  //...
  reference: Option[String],
  //...
)

🙂

heading: 'New discount'
text: 'Check your discounts tab'
owner: '@billing-team'
category: 'default'
case class PushTemplate(
  //...
  category: Option[PushCategory],
  //...
)
final class ExistingPushCategory

given Constraint[String, ExistingPushCategory] with {
  override inline def test(value: String): Boolean = PossibleCategories.contains(value)
  override inline def message: String = "Should be general or offers"
}
type PushCategory = String :| ExistingPushCategory
val PossibleCategories = Seq("general", "offers")

Improvement #5 - teams

case class PushTemplate(
  //...
  reference: Option[String],
  //...
)

😕

heading: 'New discount'
text: 'Check your discounts tab'
owner: 'Billing Team'
case class PushTemplate(
  //...
  owner: String,
  //...
)

Improvement #5 - teams

case class PushTemplate(
  //...
  reference: Option[String],
  //...
)

🙂

heading: 'New discount'
text: 'Check your discounts tab'
owner: 'Billing Team'
case class PushTemplate(
  //...
  owner: SlackHandle,
  //...
)
type SlackHandle = String :| ValidSlackHandle
final class ValidSlackHandle

given Constraint[String, ValidSlackHandle] with {
  override inline def test(value: String): Boolean = Team.slackHandles().contains(value)
  override inline def message: String = "Should be a valid Slack handle"
}
enum Team(val name: String, val slackHandle: String, val slackId: String) {
  case Operations extends Team("Operations", "@operations-team", "S01231W2333")
  case Payments extends Team("Payments and Billing", "@payments", "S1239R123")
  case QA extends Team("QA", "@quality", "S123P321")
}

object Team {
  def slackHandles(): Set[String] = Team.values.map(_.slackHandle).toSet
}

Improvement #5 - teams

Improvement #6 - parallel validation

def parseFile(
  fileContent: String
): Either[io.circe.Error, PushTemplate] = 
  io.circe.parser.decode[PushTemplate](fileContent)
heading: ''
owner: 'Billing Team'
category: 'default'
reference: 'TBD'
def parseFile(
  fileContent: String
): ValidatedNel[io.circe.Error, PushTemplate] = {
  io.circe.parser.decodeAccumulating[PushTemplate](fileContent)
}

Improvement #7 - broader validation

  • file name convention
    • push/production/new_offer.yaml
  • staging first
def validateAllFiles(
  files: List[File]
): ValidatedNel[ValidationError, List[PushTemplate]] = {
  List(
    validateSchema(files),
    validateFileNameConvention(files),
    validateStagingExists(files)
  ).reduce(_ |+| _)
}
validateAllFiles(files) match {
  case Valid(files) => //succeed
  case Invalid(errors) => //fail
}

Our dream

  • Correctness
  • Short feedback loop
  • Simplicity
  • Clarity
  • Responsibility
  • Automation

Summary

Scala 3

  • givens
  • inline
  • union types
  • enums

Iron

  • built-in constraints
  • custom constraints
  • compile time / runtime
  • integration with circe

Scala 2

Refined

Thank you

Managing templates with Iron and Scala 3

By Magda Stożek

Managing templates with Iron and Scala 3

  • 62