Is there a name for this pattern of composing a type safe return type from different levels of nested related entities?

softwareengineering.stackexchange https://softwareengineering.stackexchange.com/questions/406554

Question

I have a problem in my app where I have many entities that can all reference each other in different ways. For example, I have a Job (e.g. build house) that I might assign to a team called "Plumbers" and a separate single user called "Bob". Jobs, Teams and Users are all entities with unique ids.

When I make a request for this data I want to be able to compose the depth of data I want and if my returned type should contain ids or objects. The goal is to still keep the type safety for the return type.

For example I may just want the JobSummary, which will give me the name of the job, and the ids of the Users and the Teams assigned.

Alternatively I may want to request the full "tree" of data where IDs are resolved to actual entities. I'll call this the HydratedJob. I'm not sure if "hydrated" is the right term, but I've heard it used in this sense a lot. In this case the HydratedJob will contain a User object, and a Team object. That team object, in turn will contain a list of User objects.

I would really like to know if this is a pattern with a name that I could research more, and if anyone knows any pitfalls of this approach as I'm looking to integrate it in my app.

Below I've made an implementation of this in Kotlin to demonstrate:

All entities or entity IDs implement this:

interface Identifiable {
    val value: String
}

For the Job entity I create an interface that is either implemented by just the id, or the full entity (FlexibleJob). I call it flexible, because the job can either contain ids or objects for other entities. A type parameter specifies which of these must be provided.

interface IdentifiableJob : Identifiable
data class JobId(override val value: String) : IdentifiableJob
data class FlexibleJob<USER : IdentifiableUser, TEAM : IdentifiableTeam>(
        val jobId: JobId,
        val name: String,
        val users: List<USER>,
        val teams: List<TEAM>
) : IdentifiableJob {
    override val value = jobId.value
}

I do the same for teams and users.

interface IdentifiableTeam : Identifiable
data class TeamId(override val value: String) : IdentifiableTeam
data class FlexibleTeam<USER : IdentifiableUser>(
        val teamId: TeamId,
        val name: String,
        val users: List<USER>? = null
) : IdentifiableTeam {
    override val value = teamId.value
}

interface IdentifiableUser : Identifiable
data class UserId(override val value: String) : IdentifiableUser
data class User(val userId: UserId,
                val name: String) : IdentifiableUser {
    override val value = userId.value
}

Then I use typealiases to make the list of type parameters easier to read (there could be about 10 type parameters, with multiple nesting of type parameters :O). Here I achieve my goal of being able to compose data types with any level of nesting I want.

typealias SummaryJob = FlexibleJob<IdentifiableUser, IdentifiableTeam> // No nested objects, just ids
typealias SummaryTeam = FlexibleTeam<IdentifiableUser>
typealias HydratedTeam = FlexibleTeam<User>
typealias PartiallyHydratedJob = FlexibleJob<User, SummaryTeam>
typealias HydratedJob = FlexibleJob<User, HydratedTeam> // All objects nested, no ids

Then below you can see that type safety works

fun main() {
    val userId = UserId("user-id")
    val user = User(userId = userId, name = "Bob")
    val summaryTeam = SummaryTeam(
            teamId = TeamId("team-id"),
            name = "Plumbers",
            users = listOf(userId)
    )
    val hydratedTeam = HydratedTeam(
            teamId = TeamId("team-id"),
            name = "Plumbers",
            users = listOf(user)
    )

    // Fails with type mis-match compile error. Expecting User not UserId and expecting HydratedTeam not SummaryTeam. This is good type safety.
    val attemptFullyHydratedJobForDisplay = HydratedJob(JobId("123"),
                                                              "Build stuff",
                                                              listOf(userId),
                                                              listOf(summaryTeam))

    // Compiles
    val fullyHydratedJobForDisplay = HydratedJob(JobId("123"), "Build stuff", listOf(user), listOf(hydratedTeam))
}
Was it helpful?

Solution

It looks like you simply have 'lite' and 'full' objects, which is a pretty common anti-pattern.

Whether your object has a child object or just an Id reference is generally governed by deeper concerns than just convenience of display.

After all you can retrieve the child object separately and use it just as easily, if not more easily in most cases.

Does your Job object have methods which call child object methods?

Are child objects shared over multiple parents?

Can you update the underlying data source atomically with the child objects attached?

Can you read the underlying data efficiently to populate your chosen structure?

You may have fallen into the trap of OOP data objects, ie Cat, Dog are Animal, Animals have Legs because they are similar; rather than 'programmatic' OOP ie Array implements IEnumerable and has Items because it needs to in order to function

The alternative is to split your objects logically, for pure data this would probably be mostly 'lite' objects, and assemble as required for the function you are working on.

ie

PrintInvoice(jobId) //need child data
{
    Job job = repo.GetJob(jobId)
    Teams teams = repo.getTeamsForJob(job.Id);

    print job.Title
    foreach(id in job.TeamIds)
    {
        print teams[id].TeamName
        print teams[id].Price
    }
}

ListJobs() ///just need top level data
{
    var jobs = repo.GetJobs()
    foreach(j in jobs)
    {
       print j.Title
    }
}

Now I can assemble the information from both 'lite' and 'full' objects as I require for a specific task, I've saved huge amounts of code, If i have one team that's in two jobs, then I don't have two copies of it, if I want to list all the teams over multiple jobs I don't have to drill down to child objects and dedupe to get them etc etc

Licensed under: CC-BY-SA with attribution
scroll top