Skip to main content

Patterns: The multi-repo

There has been some comments about how the generated repositories do not match with peoples preferences of what a repository should be. For instance you may prefer that your repositories coordinate multiple tables.

And that's more than fair - Often you need to coordinate multiple tables in a transaction. The only snag is that Typo does not have the knowledge to write that code for you.

So you write code yourself

Enter the multi-repo pattern!

Here you take low-level Typo repositories as parameters, and you write the higher-level flow yourself.

You still get huge benefits from using Typo in this case:

  • All of this is typesafe
  • You get perfect auto-complete from your IDE
  • Strongly typed Id types and type flow ensure that you have to follow foreign keys correctly
  • It's fairly readable.
  • It's testable! You can even wire in stub repositories and test it all without a running database.

Just have a look at the example and think how long it would take you to write this without Typo.

With Typo, this example worked the first time it was ran.

Example

The example repo below exposes one method, which coordinates updates to four tables.

The details of what is done is probably not too important, but I tried to comment it anyway.

import adventureworks.person.address.*
import adventureworks.person.addresstype.*
import adventureworks.person.businessentityaddress.*
import adventureworks.person.countryregion.CountryregionId
import adventureworks.person.person.*
import adventureworks.public.Name
import java.sql.Connection

case class PersonWithAddresses(person: PersonRow, addresses: Map[Name, AddressRow])

class PersonWithAddressesRepo(
personRepo: PersonRepo,
businessentityAddressRepo: BusinessentityaddressRepo,
addresstypeRepo: AddresstypeRepo,
addressRepo: AddressRepo
) {

/* A person can have a bunch of addresses registered,
* and they each have an address type (BILLING, HOME, etc).
*
* This method syncs `PersonWithAddresses#addresses` to postgres,
* so that old attached addresses are removed,
* and the given addresses are attached with the chosen type
*/
def syncAddresses(pa: PersonWithAddresses)(implicit c: Connection): List[BusinessentityaddressRow] = {
// update person
personRepo.update(pa.person)
// update stored addresses
pa.addresses.toList.foreach { case (_, address) => addressRepo.update(address) }

// addresses are stored in `PersonWithAddress` by a `Name` which means what type of address it is.
// this address type is stored in addresstypeRepo.
// In order for foreign keys to align, we need to translate from names to ids, and create rows as necessary
val oldStoredAddressTypes: Map[Name, AddresstypeId] =
addresstypeRepo.select
.where(r => r.name in pa.addresses.keys.toArray)
.toList
.map(x => (x.name, x.addresstypeid))
.toMap

val currentAddressesByType: Map[AddresstypeId, AddressRow] =
pa.addresses.map { case (addressTypeName, wanted) =>
oldStoredAddressTypes.get(addressTypeName) match {
case Some(addresstypeId) => (addresstypeId, wanted)
case None =>
val inserted = addresstypeRepo.insert(AddresstypeRowUnsaved(name = addressTypeName))
(inserted.addresstypeid, wanted)
}
}

// discover existing addresses attached to person
val oldAttachedAddresses: Map[(AddressId, AddresstypeId), BusinessentityaddressRow] =
businessentityAddressRepo.select
.where(x => x.businessentityid === pa.person.businessentityid)
.toList
.map(x => ((x.addressid, x.addresstypeid), x))
.toMap

// unattach old attached addresses
oldAttachedAddresses.foreach { case (_, ba) =>
currentAddressesByType.get(ba.addresstypeid) match {
case Some(address) if address.addressid == ba.addressid =>
case _ =>
businessentityAddressRepo.deleteById(ba.compositeId)
}
}
// attach new addresses
currentAddressesByType.map { case (addresstypeId, address) =>
oldAttachedAddresses.get((address.addressid, addresstypeId)) match {
case Some(bea) => bea
case None =>
val newRow = BusinessentityaddressRowUnsaved(pa.person.businessentityid, address.addressid, addresstypeId)
businessentityAddressRepo.insert(newRow)
}
}.toList
}
}

Here is example usage:

Note that we can easily create a deep dependency graph with random data due to testInsert.

import adventureworks.{TestInsert, TestDomainInsert, withConnection}
import adventureworks.userdefined.FirstName
import scala.util.Random
import adventureworks.public.*

import scala.util.Random

object DomainInsert extends TestDomainInsert {
override def publicAccountNumber(random: Random): AccountNumber = AccountNumber(random.nextString(10))
override def publicFlag(random: Random): Flag = Flag(random.nextBoolean())
override def publicMydomain(random: Random): Mydomain = Mydomain(random.nextString(10))
override def publicName(random: Random): Name = Name(random.nextString(10))
override def publicNameStyle(random: Random): NameStyle = NameStyle(random.nextBoolean())
override def publicPhone(random: Random): Phone = Phone(random.nextString(10))
override def publicShortText(random: Random): ShortText = ShortText(random.nextString(10))
override def publicOrderNumber(random: Random): OrderNumber = OrderNumber(random.nextString(10))
}

// set a fixed seed to get consistent values
val testInsert = new TestInsert(new Random(1), DomainInsert)

val businessentityRow = testInsert.personBusinessentity()
val personRow = testInsert.personPerson(businessentityRow.businessentityid, persontype = "SC", FirstName("name"))
val countryregionRow = testInsert.personCountryregion(CountryregionId("NOR"))
val salesterritoryRow = testInsert.salesSalesterritory(countryregionRow.countryregioncode)
val stateprovinceRow = testInsert.personStateprovince(countryregionRow.countryregioncode, salesterritoryRow.territoryid)
val addressRow1 = testInsert.personAddress(stateprovinceRow.stateprovinceid)
val addressRow2 = testInsert.personAddress(stateprovinceRow.stateprovinceid)
val addressRow3 = testInsert.personAddress(stateprovinceRow.stateprovinceid)

val repo = new PersonWithAddressesRepo(
personRepo = new PersonRepoImpl,
businessentityAddressRepo = new BusinessentityaddressRepoImpl,
addresstypeRepo = new AddresstypeRepoImpl,
addressRepo = new AddressRepoImpl
)
repo.syncAddresses(PersonWithAddresses(personRow, Map(Name("HOME") -> addressRow1, Name("OFFICE") -> addressRow2)))
// res1: List[BusinessentityaddressRow] = List(
// BusinessentityaddressRow(
// businessentityid = BusinessentityId(value = 128),
// addressid = AddressId(value = 107),
// addresstypeid = AddresstypeId(value = 95),
// rowguid = TypoUUID(value = 7cca78f8-2fd6-11ef-a430-0242ac160002),
// modifieddate = TypoLocalDateTime(value = 2024-06-21T15:59:33.170008)
// ),
// BusinessentityaddressRow(
// businessentityid = BusinessentityId(value = 128),
// addressid = AddressId(value = 108),
// addresstypeid = AddresstypeId(value = 96),
// rowguid = TypoUUID(value = 7ccd0046-2fd6-11ef-a430-0242ac160002),
// modifieddate = TypoLocalDateTime(value = 2024-06-21T15:59:33.170008)
// )
// )

// check that it's idempotent
repo.syncAddresses(PersonWithAddresses(personRow, Map(Name("HOME") -> addressRow1, Name("OFFICE") -> addressRow2)))
// res2: List[BusinessentityaddressRow] = List(
// BusinessentityaddressRow(
// businessentityid = BusinessentityId(value = 128),
// addressid = AddressId(value = 107),
// addresstypeid = AddresstypeId(value = 95),
// rowguid = TypoUUID(value = 7cca78f8-2fd6-11ef-a430-0242ac160002),
// modifieddate = TypoLocalDateTime(value = 2024-06-21T15:59:33.170008)
// ),
// BusinessentityaddressRow(
// businessentityid = BusinessentityId(value = 128),
// addressid = AddressId(value = 108),
// addresstypeid = AddresstypeId(value = 96),
// rowguid = TypoUUID(value = 7ccd0046-2fd6-11ef-a430-0242ac160002),
// modifieddate = TypoLocalDateTime(value = 2024-06-21T15:59:33.170008)
// )
// )

// remove one
repo.syncAddresses(PersonWithAddresses(personRow, Map(Name("HOME") -> addressRow1)))
// res3: List[BusinessentityaddressRow] = List(
// BusinessentityaddressRow(
// businessentityid = BusinessentityId(value = 128),
// addressid = AddressId(value = 107),
// addresstypeid = AddresstypeId(value = 95),
// rowguid = TypoUUID(value = 7cca78f8-2fd6-11ef-a430-0242ac160002),
// modifieddate = TypoLocalDateTime(value = 2024-06-21T15:59:33.170008)
// )
// )

// add one
repo.syncAddresses(PersonWithAddresses(personRow, Map(Name("HOME") -> addressRow1, Name("VACATION") -> addressRow3)))
// res4: List[BusinessentityaddressRow] = List(
// BusinessentityaddressRow(
// businessentityid = BusinessentityId(value = 128),
// addressid = AddressId(value = 107),
// addresstypeid = AddresstypeId(value = 95),
// rowguid = TypoUUID(value = 7cca78f8-2fd6-11ef-a430-0242ac160002),
// modifieddate = TypoLocalDateTime(value = 2024-06-21T15:59:33.170008)
// ),
// BusinessentityaddressRow(
// businessentityid = BusinessentityId(value = 128),
// addressid = AddressId(value = 109),
// addresstypeid = AddresstypeId(value = 97),
// rowguid = TypoUUID(value = 7cd5a8e0-2fd6-11ef-a430-0242ac160002),
// modifieddate = TypoLocalDateTime(value = 2024-06-21T15:59:33.170008)
// )
// )

Isn't this a service at this point?

Maybe! You likely shouldn't use the generated Row types at the service level, and there should likely be a transaction boundary. You get to decide that, however.