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
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
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